5 Commits
564 changed files with 17939 additions and 15114 deletions

No files matched your search

-6
View File
@@ -362,9 +362,3 @@ android/fastlane/README.md.bak
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep) # Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
# bleiben eingecheckt, damit supply die Struktur vorfindet. # bleiben eingecheckt, damit supply die Struktur vorfindet.
android/fastlane/metadata/android/**/images/**/*.png android/fastlane/metadata/android/**/images/**/*.png
# Fastlane (App-Store-Upload)
ios/fastlane/report.xml
ios/fastlane/Preview.html
ios/fastlane/app-store-connect-api-key.json
ios/fastlane/screenshots/**/*.jpg
+14 -6
View File
@@ -1,6 +1,6 @@
# MarianumMobile Client # MarianumMobile Client
Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files. 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
@@ -17,7 +17,9 @@ Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. übe
``` ```
lib/ lib/
├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/) ├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/)
├── session/ Session-Modell, SessionManager, SessionLifecycle, Konten-Index ├── 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
@@ -52,9 +54,15 @@ 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()`. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE). **Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` – Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; 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. **Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box + Localstore-Collection); nur die Settings liegen in der globalen Box. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert.
**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
@@ -70,7 +78,7 @@ flutter test # Tests (siehe test
| Backend | Pfad | Zweck | | Backend | Pfad | Zweck |
|---------------------------|-----------------------|----------------------------------------| |---------------------------|-----------------------|----------------------------------------|
| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Capabilities, Push | | 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 |
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events | | MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
@@ -78,4 +86,4 @@ flutter test # Tests (siehe test
## Tests ## Tests
`test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec). 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.
+18 -3
View File
@@ -9,7 +9,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTop"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
@@ -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,7 +98,6 @@
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>
@@ -94,7 +110,6 @@
<receiver <receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget" android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
android:enabled="@bool/widget_supported"
android:label="@string/widget_week_label" android:label="@string/widget_week_label"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@@ -1,38 +1,15 @@
package eu.mhsl.marianum.mobile.client package eu.mhsl.marianum.mobile.client
import android.content.Intent import android.content.Intent
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val widgetChannel = "eu.mhsl.marianum.widget" private val widgetChannel = "eu.mhsl.marianum.widget"
private val shareChannel = "eu.mhsl.marianum.share"
/// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation` /// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation`
/// so the same intent isn't replayed on every resume. /// so the same intent isn't replayed on every resume.
private var pendingTimetableTap: Boolean = false private var pendingTimetableTap: Boolean = false
private val shareActions = setOf(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE)
/// receive_sharing_intent only emits warm shares to a live Dart listener and
/// does not buffer them. After a relaunch into an existing task the share
/// arrives via onNewIntent before Dart subscribed, so it is held here until
/// Dart reports `listenerReady`.
private var shareListenerReady = false
private var bufferedShare: Intent? = null
override fun onCreate(savedInstanceState: Bundle?) {
// Android replays the original launch intent when the activity is
// recreated from recents or after process death. A share or widget tap
// that was already handled would otherwise run again — for shares with
// temp files that are long gone.
val replayed = savedInstanceState != null ||
(intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0
if (replayed && (intent.action in shareActions || isWidgetTap(intent))) {
intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
}
super.onCreate(savedInstanceState)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -49,35 +26,17 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
shareChannel
).setMethodCallHandler { call, result ->
when (call.method) {
"listenerReady" -> {
shareListenerReady = true
bufferedShare?.let { flutterEngine.activityControlSurface.onNewIntent(it) }
bufferedShare = null
result.success(null)
}
else -> result.notImplemented()
}
}
consumeIntentData(intent) consumeIntentData(intent)
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
if (!shareListenerReady && intent.action in shareActions) {
bufferedShare = intent
}
consumeIntentData(intent) consumeIntentData(intent)
} }
private fun isWidgetTap(intent: Intent?): Boolean =
intent?.getBooleanExtra("widget_open_timetable", false) == true
private fun consumeIntentData(intent: Intent?) { private fun consumeIntentData(intent: Intent?) {
if (isWidgetTap(intent)) pendingTimetableTap = true if (intent?.getBooleanExtra("widget_open_timetable", false) == true) {
pendingTimetableTap = true
}
} }
} }
@@ -11,7 +11,7 @@ import java.util.TimeZone
// Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names // Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names
// must stay in sync. // must stay in sync.
enum class WidgetLessonStatus { enum class WidgetLessonStatus {
REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT, DUTY; REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT;
companion object { companion object {
fun fromWire(raw: String?): WidgetLessonStatus = when (raw) { fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
@@ -22,7 +22,6 @@ enum class WidgetLessonStatus {
"irregular" -> IRREGULAR "irregular" -> IRREGULAR
"teacherChanged" -> TEACHER_CHANGED "teacherChanged" -> TEACHER_CHANGED
"event" -> EVENT "event" -> EVENT
"duty" -> DUTY
else -> REGULAR else -> REGULAR
} }
} }
@@ -40,9 +39,6 @@ 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,
) )
@@ -54,12 +50,6 @@ 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,
@@ -67,8 +57,6 @@ 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 {
@@ -93,11 +81,6 @@ 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 {
@@ -119,7 +102,6 @@ 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),
) )
} }
@@ -138,18 +120,6 @@ 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(),
@@ -157,7 +127,6 @@ object WidgetDataParser {
periods = periods, periods = periods,
isHoliday = root.optBoolean("isHoliday", false), isHoliday = root.optBoolean("isHoliday", false),
holidayName = root.stringOrNull("holidayName"), holidayName = root.stringOrNull("holidayName"),
days = days,
) )
} catch (_: JSONException) { } catch (_: JSONException) {
null null
File diff suppressed because it is too large. Load diff
@@ -1,11 +0,0 @@
<?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>
@@ -1,11 +0,0 @@
<?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>
@@ -1,11 +0,0 @@
<?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>
@@ -1,7 +0,0 @@
<?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>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_cancelled" />
<stroke android:width="1.5dp" android:color="#C8FF0000" />
</shape>
</item>
<item
android:left="3dp"
android:top="3dp"
android:right="3dp"
android:bottom="3dp"
android:drawable="@drawable/widget_lesson_cancelled_x" />
</layer-list>
@@ -1,5 +1,6 @@
<?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="oval"> android:shape="rectangle">
<solid android:color="@android:color/white" /> <corners android:radius="6dp" />
<solid android:color="#FF2196F3" />
</shape> </shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF4CAF50" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FFEF6C00" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF993333" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_irregular" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_ongoing" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_past" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_regular" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_teacher_changed" />
</shape>
@@ -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="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="#C8F44336" android:strokeColor="#C8FF0000"
android:strokeWidth="2.5" android:strokeWidth="5"
android:pathData="M0,0 L100,100 M100,0 L0,100" /> android:pathData="M0,0 L100,100 M100,0 L0,100" />
</vector> </vector>
@@ -3,14 +3,6 @@
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"
<ImageView android:layout_marginEnd="2dp" />
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>
@@ -1,16 +0,0 @@
<?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>
+13 -40
View File
@@ -1,18 +1,16 @@
<?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">
<!-- Width/height/end+bottom margins are overridden in the renderer so <!-- Bottom-leading so the mark looks like it's peeking out of the lower
the mark scales with the widget size. --> left corner. Width/height/start+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"
@@ -28,68 +26,43 @@
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:paddingStart="8dp" android:padding="10dp">
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="20dp" android:layout_height="wrap_content"
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="match_parent" android:layout_height="wrap_content"
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:maxLines="1" android:singleLine="true"
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:layout_marginStart="6dp" android:textSize="10sp"
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="4dp" android:layout_marginTop="6dp"
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="30dp" android:layout_width="32dp"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
@@ -1,11 +1,8 @@
<?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"
@@ -23,126 +20,164 @@
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:paddingStart="8dp" android:padding="10dp">
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<TextView <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="20dp" android:layout_height="wrap_content"
android:gravity="start|center_vertical" android:orientation="horizontal"
android:includeFontPadding="false" android:gravity="center_vertical">
android:textSize="14dp" <TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
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:maxLines="1" android:singleLine="true"
android:text="Heute · 06.05." /> android:text="Heute · 06.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginTop="4dp" android:orientation="horizontal"
android:orientation="horizontal"> android:layout_marginTop="6dp">
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent"> <FrameLayout
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> android:layout_width="32dp"
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp"> android:layout_height="match_parent">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="2" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:25" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="72dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="3" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:15" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp">
<ImageView android:layout_width="10dp" android:layout_height="10dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_text_secondary" android:src="@drawable/widget_icon_coffee" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="122dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:20" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="158dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:10" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="194dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:00" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="230dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:05" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:50" />
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" /> android:layout_marginTop="22dp">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" /> android:textSize="11sp" android:textStyle="bold"
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break"> android:textColor="@color/widget_text_primary" android:text="1." />
<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" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
</FrameLayout> android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" /> </LinearLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" /> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" /> android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" /> android:layout_marginTop="50dp">
<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="11sp" android:textStyle="bold"
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" /> android:textColor="@color/widget_text_primary" android:text="2." />
<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"
<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" /> 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> </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">
<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"> <FrameLayout
<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_width="0dp"
<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_height="match_parent"
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" /> android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="29dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="56dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="83dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="115dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="143dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="170dp" android:background="@color/widget_divider" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="22dp"
android:layout_marginTop="29dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="MA-LK" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="55dp"
android:layout_marginTop="83dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_regular">
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="DE-GK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="9sp" android:textColor="#CCFFFFFF" android:text="B11" />
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<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"> <FrameLayout
<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_width="match_parent"
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" /> 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="KRA" /> android:layout_marginTop="143dp"
</LinearLayout> android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_cancelled">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="BIO" />
</FrameLayout> </FrameLayout>
<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"> <FrameLayout
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" /> android:layout_width="match_parent"
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" /> 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="WEB" /> android:layout_marginTop="170dp"
</LinearLayout> android:layout_marginStart="2dp"
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" /> android:layout_marginEnd="2dp"
</FrameLayout> android:padding="3dp"
<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"> 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="GE" /> 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="A110" /> 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="BER" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
</LinearLayout> </LinearLayout>
@@ -1,63 +0,0 @@
<?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,113 +1,90 @@
<?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="1dp" android:layout_marginStart="2dp"
android:layout_marginEnd="1dp" android:layout_marginEnd="2dp"
android:layout_marginTop="0dp" android:layout_marginTop="0dp"
android:background="@drawable/widget_lesson_block" android:paddingStart="7dp"
tools:ignore="SpUsage,SmallSp"> android:paddingEnd="7dp"
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:background="@drawable/widget_lesson_block_regular">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:paddingTop="2dp"
android:paddingBottom="2dp">
<LinearLayout
android:id="@+id/widget_lesson_title_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="top"
android:baselineAligned="false"> android:baselineAligned="false">
<TextView <TextView
android:id="@+id/widget_lesson_subject" android:id="@+id/widget_lesson_subject"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="14dp" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:fontFamily="sans-serif-medium" 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="start|center_vertical" android:gravity="top|start"
android:includeFontPadding="false" android:includeFontPadding="false"
android:autoSizeTextType="uniform" android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="6.5dp" android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="15dp" android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="0.5dp" android:autoSizeStepGranularity="1sp"
tools:text="MA-LK" /> tools:text="MA-LK" />
<!-- Only for tiles too short for a body line below the title. --> <LinearLayout
<TextView android:id="@+id/widget_lesson_secondary_stack"
android:id="@+id/widget_lesson_inline" android:layout_width="0dp"
android:layout_width="wrap_content" android:layout_height="match_parent"
android:layout_height="wrap_content" android:layout_weight="1"
android:layout_gravity="center_vertical" android:orientation="vertical"
android:layout_marginStart="3dp" android:gravity="top|end"
android:textColor="@android:color/white" android:layout_marginStart="4dp">
android:textSize="7dp"
android:maxLines="1"
android:includeFontPadding="false"
android:visibility="gone"
tools:text="B11" />
</LinearLayout>
<TextView <TextView
android:id="@+id/widget_lesson_room" android:id="@+id/widget_lesson_room"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="10dp" android:layout_height="12dp"
android:textColor="@android:color/white" android:textColor="#CCFFFFFF"
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="start|center_vertical" android:gravity="end|top"
android:includeFontPadding="false" android:includeFontPadding="false"
android:autoSizeTextType="uniform" android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5.5dp" android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="10dp" android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="0.5dp" android:autoSizeStepGranularity="1sp"
tools:text="A12" /> tools:text="A12" />
<TextView <TextView
android:id="@+id/widget_lesson_teacher" android:id="@+id/widget_lesson_teacher"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="10dp" android:layout_height="12dp"
android:textColor="@android:color/white" android:textColor="#B3FFFFFF"
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="start|center_vertical" android:gravity="end|top"
android:includeFontPadding="false" android:includeFontPadding="false"
android:autoSizeTextType="uniform" android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5.5dp" android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="10dp" android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="0.5dp" android:autoSizeStepGranularity="1sp"
tools:text="Müller" /> tools:text="Müller" />
</LinearLayout> </LinearLayout>
</LinearLayout>
<FrameLayout
android:id="@+id/widget_lesson_cancelled"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_lesson_cancelled_overlay"
android:visibility="gone" />
<TextView <TextView
android:id="@+id/widget_lesson_sibling_badge" android:id="@+id/widget_lesson_sibling_badge"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|end" android:layout_gravity="bottom|start"
android:layout_marginEnd="4dp"
android:layout_marginBottom="2dp"
android:textStyle="bold" android:textStyle="bold"
android:textSize="9dp" android:textSize="12sp"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:maxLines="1" android:maxLines="1"
android:includeFontPadding="false" android:includeFontPadding="false"
@@ -1,50 +1,31 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- One period cell of the ruler, mirroring the in-app _PeriodLabel: number <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
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="36dp" android:layout_height="wrap_content"
android:layout_marginTop="0dp" android:layout_marginTop="0dp"
tools:ignore="SpUsage,SmallSp"> android:orientation="vertical"
android:gravity="end"
android:paddingEnd="3dp">
<TextView <TextView
android:id="@+id/widget_time_label_start" android:id="@+id/widget_time_label_time"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="top" android:textSize="9sp"
android:layout_marginTop="2dp" android:textColor="@color/widget_text_primary"
android:gravity="center_horizontal" android:singleLine="true"
android:textSize="7dp" android:lineSpacingExtra="-2dp"
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="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_vertical" android:textSize="7sp"
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:maxLines="1" android:singleLine="true"
android:includeFontPadding="false" android:lineSpacingExtra="-2dp"
tools:text="08:40" /> tools:text="1." />
</FrameLayout> </LinearLayout>
+18 -36
View File
@@ -1,18 +1,11 @@
<?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"
@@ -28,53 +21,42 @@
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:paddingStart="8dp" android:padding="10dp">
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="20dp" android:layout_height="wrap_content"
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="match_parent" android:layout_height="wrap_content"
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:maxLines="1" android:singleLine="true"
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:layout_marginStart="6dp" android:textSize="10sp"
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="32dp" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:orientation="horizontal"
android:orientation="horizontal"> android:layout_marginTop="6dp">
<FrameLayout <FrameLayout
android:layout_width="30dp" android:layout_width="28dp"
android:layout_height="match_parent" /> android:layout_height="wrap_content" />
<FrameLayout <FrameLayout
android:layout_width="1dp" android:layout_width="1dp"
@@ -84,7 +66,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="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -95,7 +77,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="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -106,7 +88,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="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -117,7 +99,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="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -128,7 +110,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="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" /> android:layout_weight="1" />
</LinearLayout> </LinearLayout>
@@ -141,7 +123,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_time_labels" android:id="@+id/widget_week_time_labels"
android:layout_width="30dp" android:layout_width="28dp"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
@@ -1,35 +1,28 @@
<?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="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center" android:gravity="center"
tools:ignore="SpUsage,SmallSp"> android:paddingTop="2dp"
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:fontFamily="sans-serif-medium" android:textSize="11sp"
android:textSize="9dp" android:textStyle="bold"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_primary"
android:maxLines="1" tools:text="Mo" />
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="18dp" android:layout_width="wrap_content"
android:layout_height="18dp" android:layout_height="wrap_content"
android:layout_marginTop="1dp" android:textSize="9sp"
android:gravity="center" android:textColor="@color/widget_text_secondary"
android:textSize="11dp" tools:text="06.05." />
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="6" />
</LinearLayout> </LinearLayout>
@@ -1,11 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Static picker preview of the week widget, laid out like WidgetRenderer's output. --> <!-- Static preview for the week widget. Five day columns with two short
demo blocks each so the structure is recognisable in the picker. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <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"
@@ -23,312 +22,124 @@
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:paddingStart="8dp" android:padding="10dp">
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="32dp" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:orientation="horizontal"
android:orientation="horizontal"> android:gravity="center_vertical">
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent" /> <TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="KW 19 · 06.05.–10.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<!-- Day-name + date row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp">
<FrameLayout android:layout_width="20dp" android:layout_height="wrap_content" />
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android: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="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mo" />
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="06.05." />
</LinearLayout> </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="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android: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="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Di" />
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="07.05." />
</LinearLayout> </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="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android: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="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mi" />
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="08.05." />
</LinearLayout> </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="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android: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="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Do" />
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="09.05." />
</LinearLayout> </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="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android: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="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Fr" />
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="8" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="10.05." />
</LinearLayout> </LinearLayout>
</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:layout_marginTop="2dp" android:orientation="horizontal"
android:orientation="horizontal"> android:layout_marginTop="2dp">
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent"> <FrameLayout android:layout_width="20dp" android:layout_height="match_parent">
<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="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="36dp" android:layout_marginTop="0dp"> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="36dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="10" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_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="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" 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" /> <!-- Mon -->
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="2" /> <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:25" /> <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="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" /> <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="36dp" android:layout_marginTop="72dp"> <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" />
<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="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> <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="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="BIO" />
<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="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" /> <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="158dp" 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="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="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> <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="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="MA" />
<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="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> <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="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="GE" />
<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="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" /> <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="158dp" 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="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="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> <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="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="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,6 +4,4 @@
<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>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="widget_supported">true</bool>
</resources>
@@ -6,7 +6,6 @@
<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,9 +1,17 @@
<?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>
@@ -1,7 +0,0 @@
<?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>
-1
View File
@@ -4,5 +4,4 @@ targets:
json_serializable: json_serializable:
options: options:
explicit_to_json: false explicit_to_json: false
checked: true
generic_argument_factories: true generic_argument_factories: true
+5 -22
View File
@@ -17,8 +17,7 @@ import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich /// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
/// und Einstellungen. Ausgeführt über `flutter drive` mit /// und Einstellungen. Ausgeführt über `flutter drive` mit
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh /// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
/// (Android: Phone / 7″ / 10″) bzw. tool/screenshots_ios.sh (iPhone 6,5″ / /// (Phone / 7″ / 10″).
/// iPad 13″).
/// ///
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus /// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
/// Fixtures, daher ist der Lauf deterministisch. /// Fixtures, daher ist der Lauf deterministisch.
@@ -43,13 +42,11 @@ Future<void> main() async {
_log('Login'); _log('Login');
await _login(tester); await _login(tester);
// Großzügig: auf einem frisch angelegten iOS-Simulator dauert der erste
// Post-Login-Aufbau deutlich länger als 20s.
_log('warte auf App-Shell'); _log('warte auf App-Shell');
await _pumpUntil( await _pumpUntil(
tester, tester,
find.byType(App), find.byType(App),
timeout: const Duration(seconds: 60), timeout: const Duration(seconds: 20),
); );
_log('warte auf Post-Login-Splash-Ende'); _log('warte auf Post-Login-Splash-Ende');
await _pumpUntilGone( await _pumpUntilGone(
@@ -67,12 +64,6 @@ Future<void> main() async {
await binding.convertFlutterSurfaceToImage(); await binding.convertFlutterSurfaceToImage();
} }
// Den einmaligen Benachrichtigungs-Dialog beim ersten Talk-Besuch
// unterdrücken. Auf Android erteilt tool/screenshots.sh die Permission
// vorab, auf iOS lässt sie sich im Simulator nicht vorab erteilen – der
// Dialog würde sonst über der Talk-Liste liegen.
_markTalkPermissionPromptShown(tester);
// 1 + 2: Stundenplan in beiden Themes. // 1 + 2: Stundenplan in beiden Themes.
await _goTo(tester, Modules.timetable); await _goTo(tester, Modules.timetable);
_setTheme(tester, ThemeMode.light); _setTheme(tester, ThemeMode.light);
@@ -115,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',
@@ -138,16 +131,6 @@ void _setTheme(WidgetTester tester, ThemeMode mode) {
context.read<SettingsCubit>().val(write: true).appTheme = mode; context.read<SettingsCubit>().val(write: true).appTheme = mode;
} }
void _markTalkPermissionPromptShown(WidgetTester tester) {
final context = tester.element(find.byType(App));
context
.read<SettingsCubit>()
.val(write: true)
.notificationSettings
.talkPermissionPromptShown =
true;
}
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt, /// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt,
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert. /// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
Future<void> _goTo(WidgetTester tester, Modules module) async { Future<void> _goTo(WidgetTester tester, Modules module) async {
+2 -2
View File
@@ -180,11 +180,11 @@ AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = <key>` +
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) | | `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart | | `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart | | `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
| `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) | | `nextcloud_app_password` | NC App-Password | `account_data.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 im **Default**-Storage des SessionManagers > `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage
> **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
+6 -5
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 70; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -77,19 +77,19 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; 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; path = NotificationService.swift; sourceTree = "<group>"; }; 2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.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; path = PEM.swift; sourceTree = "<group>"; }; 509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; };
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; }; 5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.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>"; };
@@ -222,6 +222,7 @@
12B96C78930441C73F123636 /* Info.plist */, 12B96C78930441C73F123636 /* Info.plist */,
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */, 5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
); );
name = NotificationServiceExtension;
path = NotificationServiceExtension; path = NotificationServiceExtension;
sourceTree = SOURCE_ROOT; sourceTree = SOURCE_ROOT;
}; };
@@ -14,8 +14,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git", "location" : "https://github.com/google/app-check.git",
"state" : { "state" : {
"revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a", "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
"version" : "11.3.2" "version" : "11.3.1"
} }
}, },
{ {
@@ -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" : "27eaab3918e0bf78711cf1abf240577176326432", "revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
"version" : "12.19.0" "version" : "12.15.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" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee", "revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
"version" : "3.7.0" "version" : "3.6.1"
} }
}, },
{ {
@@ -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" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f", "revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
"version" : "12.19.2" "version" : "12.15.0"
} }
}, },
{ {
@@ -77,8 +77,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git", "location" : "https://github.com/google/GoogleDataTransport.git",
"state" : { "state" : {
"revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", "revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.1" "version" : "10.1.0"
} }
}, },
{ {
@@ -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" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3", "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
"version" : "8.1.3" "version" : "8.1.2"
} }
}, },
{ {
@@ -104,8 +104,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git", "location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : { "state" : {
"revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.1" "version" : "5.3.0"
} }
}, },
{ {
+6 -4
View File
@@ -4,10 +4,6 @@
<dict> <dict>
<key>AppGroupId</key> <key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string> <string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>eu.mhsl.marianum.widget.refresh</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
@@ -41,6 +37,8 @@
</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>
@@ -70,6 +68,10 @@
</dict> </dict>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>eu.mhsl.marianum.widget.refresh</string>
</array>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string> <string>fetch</string>
+5
View File
@@ -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>
+2 -25
View File
@@ -13,44 +13,21 @@ class SceneDelegate: FlutterSceneDelegate {
options connectionOptions: UIScene.ConnectionOptions options connectionOptions: UIScene.ConnectionOptions
) { ) {
super.scene(scene, willConnectTo: session, options: connectionOptions) super.scene(scene, willConnectTo: session, options: connectionOptions)
var handledShare = false for context in connectionOptions.urlContexts {
for context in connectionOptions.urlContexts where isShareUrl(context.url) {
_ = ReceiveSharingIntentPlugin.instance.application( _ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared, UIApplication.shared,
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url] didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
) )
handledShare = true
} }
if handledShare { clearSharedPayload() }
} }
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
let shares = URLContexts.filter { isShareUrl($0.url) } for context in URLContexts {
for context in shares {
_ = ReceiveSharingIntentPlugin.instance.application( _ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared, UIApplication.shared,
open: context.url, open: context.url,
options: [:] options: [:]
) )
} }
if !shares.isEmpty { clearSharedPayload() }
let others = URLContexts.subtracting(shares)
if !others.isEmpty { super.scene(scene, openURLContexts: others) }
}
private func isShareUrl(_ url: URL) -> Bool {
ReceiveSharingIntentPlugin.instance.hasMatchingSchemePrefix(url: url)
}
// The plugin keeps the payload in memory once read but never removes it
// from the App Group defaults. The Share Extension opens the app twice per
// share and a scene reconnect replays the URL, so every later delivery
// would publish the finished share again.
private func clearSharedPayload() {
let groupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String
let defaults = UserDefaults(
suiteName: groupId ?? "group.\(Bundle.main.bundleIdentifier ?? "")")
defaults?.removeObject(forKey: "ShareKey")
defaults?.removeObject(forKey: "ShareMessageKey")
} }
} }
+18 -127
View File
@@ -107,58 +107,20 @@ final class ShareViewController: UIViewController {
(UTType.url.identifier, .url), (UTType.url.identifier, .url),
(UTType.data.identifier, .file), (UTType.data.identifier, .file),
] ]
for (typeId, kind) in order where provider.hasItemConformingToTypeIdentifier(typeId) { for (typeId, kind) in order {
// Konkreter Typ (z. B. public.png statt public.image) für Dateiendung if provider.hasItemConformingToTypeIdentifier(typeId) {
// und MIME-Type, falls die Quelle nur rohe Daten liefert.
let concrete = concreteType(of: provider, conformingTo: typeId)
provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in
guard let self else { completion(nil); return } guard let self else { completion(nil); return }
if error == nil, if error != nil { completion(nil); return }
let file = self.toSharedFile(data: data, kind: kind, completion(self.toSharedFile(data: data, kind: kind))
concrete: concrete, provider: provider) {
completion(file)
return
}
// Manche Quellen (z. B. der Screenshot-/Markup-Editor) liefern über
// loadItem nichts Verwertbares oder eine URL außerhalb unserer
// Sandbox. loadFileRepresentation stellt eine lesbare Kopie bereit.
self.loadFileFallback(provider: provider, kind: kind,
concrete: concrete, completion: completion)
} }
return return
} }
}
completion(nil) completion(nil)
} }
private func concreteType(of provider: NSItemProvider, conformingTo typeId: String) -> UTType? { private func toSharedFile(data: Any?, kind: SharedMediaType) -> SharedMediaFile? {
guard let base = UTType(typeId) else { return nil }
return provider.registeredTypeIdentifiers
.compactMap { UTType($0) }
.first { $0.conforms(to: base) } ?? base
}
private func loadFileFallback(provider: NSItemProvider, kind: SharedMediaType,
concrete: UTType?,
completion: @escaping (SharedMediaFile?) -> Void) {
guard kind == .image || kind == .video || kind == .file, let concrete else {
completion(nil)
return
}
provider.loadFileRepresentation(forTypeIdentifier: concrete.identifier) { [weak self] url, _ in
// Die URL ist nur innerhalb dieses Callbacks gültig — sofort kopieren.
guard let self, let url, let dst = self.copyIntoAppGroup(src: url) else {
completion(nil)
return
}
let mime = self.mime(for: url) ?? concrete.preferredMIMEType
let duration = kind == .video ? self.videoDurationMs(url: dst) : nil
completion(SharedMediaFile(path: self.pathString(for: dst), mimeType: mime,
thumbnail: nil, duration: duration, message: nil, type: kind))
}
}
private func toSharedFile(data: Any?, kind: SharedMediaType, concrete: UTType?,
provider: NSItemProvider) -> SharedMediaFile? {
switch kind { switch kind {
case .text: case .text:
guard let s = data as? String else { return nil } guard let s = data as? String else { return nil }
@@ -177,10 +139,6 @@ final class ShareViewController: UIViewController {
return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png", return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png",
thumbnail: nil, duration: nil, message: nil, type: .image) thumbnail: nil, duration: nil, message: nil, type: .image)
} }
if let d = data as? Data, let dst = writeData(d, type: concrete, provider: provider) {
return SharedMediaFile(path: pathString(for: dst), mimeType: concrete?.preferredMIMEType,
thumbnail: nil, duration: nil, message: nil, type: .image)
}
return nil return nil
case .video: case .video:
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil } guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
@@ -188,64 +146,23 @@ final class ShareViewController: UIViewController {
thumbnail: nil, duration: videoDurationMs(url: u), thumbnail: nil, duration: videoDurationMs(url: u),
message: nil, type: .video) message: nil, type: .video)
case .file: case .file:
if let u = data as? URL, let dst = copyIntoAppGroup(src: u) { guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u), return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
thumbnail: nil, duration: nil, message: nil, type: .file) thumbnail: nil, duration: nil, message: nil, type: .file)
} }
if let d = data as? Data, let dst = writeData(d, type: concrete, provider: provider) {
return SharedMediaFile(path: pathString(for: dst), mimeType: concrete?.preferredMIMEType,
thumbnail: nil, duration: nil, message: nil, type: .file)
}
return nil
}
}
// loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell.
private let fileQueue = DispatchQueue(label: "share.files")
// Ein Ordner pro Share: Gleichnamige Dateien (z. B. bearbeitete Fotos, alle
// "FullSizeRender.jpg") überschrieben sich sonst gegenseitig, auch über
// zwei Shares hinweg. Die App löscht den Ordner, sobald der Share erledigt ist.
private lazy var shareDir: URL? = {
guard let container = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
return nil
}
let root = container.appendingPathComponent("share_intent", isDirectory: true)
sweepStaleShares(in: root)
let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
} catch {
return nil
}
}()
// Shares, deren App-Flow nie abgeschlossen wurde (Prozess beendet o. ä.),
// räumt sonst niemand weg — der App-Group-Container wird nie geleert.
private func sweepStaleShares(in root: URL) {
let fm = FileManager.default
guard let entries = try? fm.contentsOfDirectory(
at: root, includingPropertiesForKeys: [.creationDateKey]) else { return }
let cutoff = Date().addingTimeInterval(-24 * 60 * 60)
for entry in entries {
let created = (try? entry.resourceValues(forKeys: [.creationDateKey]))?.creationDate
if let created, created < cutoff { try? fm.removeItem(at: entry) }
}
} }
private func copyIntoAppGroup(src: URL) -> URL? { private func copyIntoAppGroup(src: URL) -> URL? {
fileQueue.sync { copyIntoShareDir(src: src) } guard let container = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
return nil
} }
private func copyIntoShareDir(src: URL) -> URL? {
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
guard let dst = uniqueDestination(name: name) else { return nil } let dst = container.appendingPathComponent(name)
// Security-scoped URLs (z. B. aus "Dateien") sind sonst nicht lesbar.
let scoped = src.startAccessingSecurityScopedResource()
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
do { do {
if FileManager.default.fileExists(atPath: dst.path) {
try FileManager.default.removeItem(at: dst)
}
try FileManager.default.copyItem(at: src, to: dst) try FileManager.default.copyItem(at: src, to: dst)
return dst return dst
} catch { } catch {
@@ -253,37 +170,11 @@ final class ShareViewController: UIViewController {
} }
} }
// Nur auf fileQueue aufrufen.
private func uniqueDestination(name: String) -> URL? {
guard let dir = shareDir else { return nil }
let dst = dir.appendingPathComponent(name)
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares.
if FileManager.default.fileExists(atPath: dst.path) {
return dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)")
}
return dst
}
private func writeData(_ data: Data, type: UTType?, provider: NSItemProvider) -> URL? {
let ext = type?.preferredFilenameExtension ?? "bin"
var base = (provider.suggestedName ?? UUID().uuidString)
.replacingOccurrences(of: "/", with: "-")
if (base as NSString).pathExtension.lowercased() == ext.lowercased() {
base = (base as NSString).deletingPathExtension
}
// Namenswahl und Schreiben zusammen seriell, sonst können zwei Anhänge
// mit gleichem suggestedName denselben Pfad bekommen.
return fileQueue.sync {
guard let dst = uniqueDestination(name: "\(base).\(ext)"),
(try? data.write(to: dst)) != nil else { return nil }
return dst
}
}
private func writePng(image: UIImage) -> URL? { private func writePng(image: UIImage) -> URL? {
guard let data = image.pngData(), guard let container = FileManager.default
let dir = fileQueue.sync(execute: { shareDir }) else { return nil } .containerURL(forSecurityApplicationGroupIdentifier: appGroupId),
let dst = dir.appendingPathComponent("\(UUID().uuidString).png") let data = image.pngData() else { return nil }
let dst = container.appendingPathComponent("\(UUID().uuidString).png")
return (try? data.write(to: dst)) != nil ? dst : nil return (try? data.write(to: dst)) != nil ? dst : nil
} }
@@ -1,6 +1,59 @@
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
@@ -20,7 +73,7 @@ struct TimetableDayView: View {
@ViewBuilder @ViewBuilder
private func content(data: WidgetTimetableData) -> some View { private func content(data: WidgetTimetableData) -> some View {
VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) { VStack(alignment: .leading, spacing: 3) {
header(data: data) header(data: data)
if data.isHoliday { if data.isHoliday {
emptyState(text: data.holidayName ?? "Ferien") emptyState(text: data.holidayName ?? "Ferien")
@@ -28,41 +81,37 @@ struct TimetableDayView: View {
emptyState(text: "Keine Stunden") emptyState(text: "Keine Stunden")
} else { } else {
GeometryReader { geo in GeometryReader { geo in
let periods = trimTrailingPeriods(data.periods, lessons: data.lessons) let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
HStack(alignment: .top, spacing: 0) {
PeriodRuler(
periods: periods,
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
)
Rectangle()
.fill(palette.divider)
.frame(width: 1)
TimeGridView( TimeGridView(
lessons: data.lessons, lessons: data.lessons,
periods: periods, periods: data.periods,
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods) hourHeight: max(
MIN_HOUR_HEIGHT,
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
),
showRoom: true,
showTeacher: true,
showTimeLabels: true
) )
} }
} }
} }
} }
}
private func header(data: WidgetTimetableData) -> some View { private func header(data: WidgetTimetableData) -> some View {
HStack(spacing: 6) { HStack(spacing: 4) {
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date)) Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
.font(.system(size: 14, weight: .bold)) .font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.65) .minimumScaleFactor(0.7)
.frame(maxWidth: .infinity, alignment: .leading) Spacer(minLength: 4)
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 10)) .font(.system(size: 9))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.fixedSize() .minimumScaleFactor(0.7)
} }
.frame(height: TITLE_ROW_HEIGHT)
} }
private func emptyState(text: String) -> some View { private func emptyState(text: String) -> some View {
@@ -90,23 +139,54 @@ struct TimetableDayView: View {
} }
} }
/// Mirrors the in-app _PeriodLabel: period number centred, start time above, struct TimeGridView: View {
/// end time below, a cup in the breaks. let lessons: [WidgetLesson]
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 totalHeight: CGFloat { private var totalVirtualMinutes: Int {
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0 periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
} }
private var totalHeight: CGFloat {
CGFloat(totalVirtualMinutes) * hourHeight / 60.0
}
/// Below this per-hour height the two-line label collapses to a single
/// period number — time + number overlap otherwise.
private var compactLabels: Bool { hourHeight < 26 }
var body: some View { var body: some View {
// Every child has layout-y 0 (`.offset` only shifts the rendering), HStack(alignment: .top, spacing: 0) {
// so both the ZStack and its frame need `.top` — the default if showTimeLabels {
// `.center` would anchor the whole ruler at the column's midpoint. // 28pt matches the Week widget's time-label column so the
// visual rhythm is identical across both widgets.
timeLabelsColumn
.frame(width: 28, alignment: .topTrailing)
Rectangle()
.fill(palette.divider)
.frame(width: 1)
}
ZStack(alignment: .top) { 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)
@@ -114,91 +194,39 @@ struct PeriodRuler: 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
cell(period) VStack(alignment: .trailing, spacing: -2) {
} if compactLabels {
ForEach(periodBreaks(periods), id: \.self) { pause in Text("\(period.name).")
let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0 .font(.system(size: 9, weight: .bold))
if height >= BREAK_ICON_MIN_HEIGHT {
Image(systemName: "cup.and.saucer")
.font(.system(size: 8))
.foregroundStyle(palette.textSecondary)
.frame(width: RULER_WIDTH, height: height)
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
}
}
}
.frame(width: RULER_WIDTH, height: totalHeight, alignment: .top)
}
private func cell(_ period: WidgetPeriod) -> some View {
let height = CGFloat(period.virtualEndMinutes - period.virtualStartMinutes) * hourHeight / 60.0
let timeFont = max(6, min(8.5, height * 0.19))
return ZStack {
Text(period.name)
.font(.system(size: max(8, min(13, height * 0.3)), weight: .medium))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
if height >= RULER_TIMES_MIN_CELL { } else {
VStack(spacing: 0) {
Text(formatHm(period.startMinutes)) Text(formatHm(period.startMinutes))
Spacer(minLength: 0) .font(.system(size: 9))
Text(formatHm(period.endMinutes)) .foregroundStyle(palette.textPrimary)
} .lineLimit(1)
.font(.system(size: timeFont)) Text("\(period.name).")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.padding(.vertical, 2)
} }
} }
.frame(width: RULER_WIDTH, height: height) .padding(.trailing, 4)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
} }
private func formatHm(_ minutes: Int) -> String {
String(format: "%02d:%02d", minutes / 60, minutes % 60)
} }
} .frame(height: totalHeight, alignment: .topTrailing)
/// One day column: grid lines, break bands and the lesson tiles. Shared by
/// the day widget and each of the week widget's five columns.
struct TimeGridView: View {
let lessons: [WidgetLesson]
let periods: [WidgetPeriod]
let hourHeight: CGFloat
var holiday: WidgetDayInfo? = nil
var isToday: Bool = false
@Environment(\.widgetPalette) private var palette
private var totalHeight: CGFloat {
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
}
var body: some View {
GeometryReader { geo in
let metrics = TileMetrics(periods: periods, hourHeight: hourHeight)
let innerWidth = geo.size.width - 2 * (TILE_MARGIN_H + TILE_PADDING_H)
ZStack(alignment: .top) {
if let holiday {
holidayColumn(holiday)
} else {
if isToday {
palette.todayTint.frame(height: totalHeight)
}
gridLines
breakBlocks
}
ForEach(lessons.indices, id: \.self) { idx in
lessonBlock(lessons[idx], metrics: metrics, innerWidth: innerWidth)
}
}
.frame(width: geo.size.width, height: totalHeight, alignment: .top)
}
.frame(height: totalHeight)
} }
private var gridLines: some View { private var gridLines: some View {
// `.top` on both the ZStack and the frame — see PeriodRuler. // `.frame(height: totalHeight, alignment: .top)` — without the
// explicit `.top`, SwiftUI's frame modifier defaults to `.center`.
// Every Rectangle here has layout-y=0 (the `.offset()`s shift the
// rendered position only, not layout), so the inner ZStack's
// natural height is just 1pt; a 1pt ZStack centred in a 300pt
// frame anchors at the middle, and every line then renders
// relative to that midpoint — the grid visually starts halfway
// down the column and the later periods clip past the bottom.
ZStack(alignment: .top) { ZStack(alignment: .top) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle() Rectangle()
@@ -211,136 +239,103 @@ 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(periodBreaks(periods), id: \.self) { pause in ForEach(0..<max(0, periods.count - 1), id: \.self) { i in
let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0 let curr = periods[i]
ZStack { let next = periods[i + 1]
palette.breakBlock let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes
if height >= BREAK_ICON_MIN_HEIGHT { if virtualGap > 0 {
Image(systemName: "fork.knife") Rectangle()
.font(.system(size: 9)) .fill(palette.breakBlock)
.foregroundStyle(palette.accent) .frame(height: CGFloat(virtualGap) * hourHeight / 60.0)
.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)
} }
/// Mirrors the in-app holiday region: icon and "Schulfrei" on top, the private func formatHm(_ minutes: Int) -> String {
/// holiday name rotated along the column. String(format: "%02d:%02d", minutes / 60, minutes % 60)
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( private func lessonBlock(_ lesson: WidgetLesson) -> some View {
_ lesson: WidgetLesson, let cal = Calendar.current
metrics: TileMetrics, let comps = cal.dateComponents([.hour, .minute], from: lesson.start)
innerWidth: CGFloat let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
) -> 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
) )
LessonTile( let subjectSize = subjectFont(forHourHeight: hourHeight)
lesson: lesson, let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2)
height: height, let room = lesson.room
metrics: metrics, let teacher = lesson.teacher ?? lesson.originalTeacher
layout: TileTextLayout( let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false)
lesson: lesson, HStack(alignment: .top, spacing: 4) {
innerHeight: height - 2 * TILE_PADDING_V, Text(subjectLabel(lesson))
innerWidth: innerWidth, .font(.system(size: subjectSize, weight: .semibold))
metrics: metrics
)
)
.offset(y: top)
}
}
}
/// Mirrors the in-app AppointmentTile: title on top, room and teacher
/// stacked below, each line shrinking on its own to fit the tile width.
private struct LessonTile: View {
let lesson: WidgetLesson
let height: CGFloat
let metrics: TileMetrics
let layout: TileTextLayout
private static let cancelledRed =
Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255)
var body: some View {
let shape = RoundedRectangle(cornerRadius: TILE_CORNER_RADIUS)
VStack(alignment: .leading, spacing: 0) {
titleRow
ForEach(Array(layout.body.enumerated()), id: \.offset) { index, line in
Text(line)
.font(.system(size: metrics.bodyFont))
.lineLimit(1)
.minimumScaleFactor(MIN_BODY_LINE_FONT / metrics.bodyFont)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: metrics.bodyBox)
.padding(.trailing, badgeInset(line: index + 1))
}
}
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, TILE_PADDING_H) .lineLimit(hasSecondary ? 1 : 2)
.padding(.vertical, TILE_PADDING_V) .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(maxWidth: .infinity, alignment: .topLeading)
.frame(height: height, alignment: .topLeading) .frame(height: height, alignment: .topLeading)
.background(lesson.tileColor) .background(blockColor(lesson))
.clipShape(shape) .cornerRadius(6)
.overlay(alignment: .bottomTrailing) { .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 { if let count = lesson.siblingCount, count > 0 {
Text("+\(count)") Text("+\(count)")
.font(.system(size: 9, weight: .bold)) .font(.system(size: 12, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.trailing, TILE_PADDING_H) .padding(.leading, horizontalPadding)
.padding(.bottom, TILE_PADDING_V) .padding(.bottom, 2)
} }
} }
.overlay { .overlay {
// CrossPainter parity: clip cross to the rounded shape so
// the diagonals don't bleed past the corners.
if lesson.status == .cancelled { if lesson.status == .cancelled {
ZStack { ZStack {
shape.strokeBorder(LessonTile.cancelledRed, lineWidth: 2) RoundedRectangle(cornerRadius: 6)
.stroke(Color.red.opacity(0.78), lineWidth: 1.5)
GeometryReader { geo in GeometryReader { geo in
Path { p in Path { p in
p.move(to: .zero) p.move(to: .zero)
@@ -348,37 +343,63 @@ private struct LessonTile: View {
p.move(to: CGPoint(x: geo.size.width, y: 0)) p.move(to: CGPoint(x: geo.size.width, y: 0))
p.addLine(to: CGPoint(x: 0, y: geo.size.height)) p.addLine(to: CGPoint(x: 0, y: geo.size.height))
} }
.stroke(LessonTile.cancelledRed, lineWidth: 2) .stroke(Color.red.opacity(0.78), lineWidth: 3)
} }
} }
.clipShape(shape) .clipShape(RoundedRectangle(cornerRadius: 6))
} }
} }
.padding(.horizontal, TILE_MARGIN_H) .padding(.horizontal, showRoom ? 2 : 1)
.offset(y: top)
}
} }
private var titleRow: some View { private func subjectLabel(_ lesson: WidgetLesson) -> String {
HStack(alignment: .center, spacing: 3) { !lesson.subjectShort.isEmpty
Text(layout.title) ? lesson.subjectShort
.font(.system(size: layout.titleFont, weight: .medium)) : (lesson.subjectLong ?? "—")
.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 { /// Mirrors lesson_color.dart + custom_event_colors.dart so the widget
layout.badgeLine == line ? BADGE_WIDTH : 0 /// matches the in-app calendar exactly.
private func blockColor(_ lesson: WidgetLesson) -> Color {
if lesson.status == .event, let custom = lesson.customColor {
switch custom {
case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0)
case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0)
default: break
} }
} }
switch lesson.status {
// `.ongoing` deliberately collapses into the regular red — the
// widget timeline only ticks every ~30min, so highlighting the
// "current" lesson would be stale most of the day and misleads
// more than it helps.
case .regular, .past, .ongoing:
return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
case .cancelled: return .black
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
case .duty: return Color(red: 0/255.0, green: 121/255.0, blue: 107/255.0)
}
}
}
/// Period boundaries deduped: adjacent periods share a line, periods on
/// either side of a break get their own (bracketing the break block).
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
var seen = Set<Int>()
var result: [Int] = []
for p in periods {
for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
if seen.insert(v).inserted { result.append(v) }
}
}
return result.sorted()
}
/// Fixed-locale formatters cached once — DateFormatter setup is the /// Fixed-locale formatters cached once — DateFormatter setup is the
/// expensive part and the multi-entry timelines render up to ~8 entries per /// expensive part and the multi-entry timelines render up to ~8 entries per
@@ -1,279 +0,0 @@
import SwiftUI
import UIKit
// Layout constants and rules — must mirror WidgetRenderer.kt on Android,
// otherwise the platforms drift apart on the same widget size.
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
let FALLBACK_SLOT_MINUTES = 45
/// Trailing periods without lessons are cut off so the tiles get more
/// height — but never below this many periods, or a short day would blow up
/// to a handful of oversized tiles.
let MIN_VISIBLE_PERIODS = 6
let MIN_HOUR_HEIGHT: CGFloat = 18
let MAX_HOUR_HEIGHT: CGFloat = 72
let MIN_BLOCK_HEIGHT: CGFloat = 16
let LESSON_GAP: CGFloat = 1.5
let TITLE_ROW_HEIGHT: CGFloat = 20
let GRID_MARGIN_TOP: CGFloat = 4
let WEEK_HEADER_HEIGHT: CGFloat = 32
let RULER_WIDTH: CGFloat = 30
let TILE_MARGIN_H: CGFloat = 1
let TILE_PADDING_H: CGFloat = 4
let TILE_PADDING_V: CGFloat = 2
let TILE_CORNER_RADIUS: CGFloat = 7
// In-app tile: 15sp title over 10sp body lines. The widget keeps that ratio
// and scales both so title + room + teacher fill one period.
let TITLE_TO_BODY_RATIO: CGFloat = 1.5
let MAX_BODY_FONT: CGFloat = 10
let MIN_BODY_FONT: CGFloat = 6.5
let MIN_TITLE_FONT: CGFloat = 6.5
let MIN_BODY_LINE_FONT: CGFloat = 5.5
let LINE_BOX_PER_FONT: CGFloat = 1.2
/// If stacking room and teacher would push the body font below this, both
/// share one line instead so the text stays readable.
let STACKED_BODY_MIN_FONT: CGFloat = 7.5
/// A title that needs to shrink below this to fit on one line wraps onto a
/// second line instead, if the tile has the height for it.
let TITLE_WRAP_BELOW_FONT: CGFloat = 8
// Footprint of the "+N" badge in the tile's bottom trailing corner.
let BADGE_WIDTH: CGFloat = 16
let BADGE_HEIGHT: CGFloat = 11
let RULER_TIMES_MIN_CELL: CGFloat = 30
let BREAK_ICON_MIN_HEIGHT: CGFloat = 12
/// Space the holiday column keeps free above and below the rotated name for
/// the icon and the "Schulfrei" label.
let HOLIDAY_NAME_INSET: CGFloat = 44
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
guard !periods.isEmpty else { return CGFloat(realMin) }
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
}
let first = periods.first!
if realMin < first.startMinutes {
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
}
let last = periods.last!
if realMin > last.endMinutes {
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
}
var prev = first
for i in 1..<periods.count {
let curr = periods[i]
if realMin > prev.endMinutes && realMin < curr.startMinutes {
let gap = curr.startMinutes - prev.endMinutes
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
if gap > 0 {
return CGFloat(prev.virtualEndMinutes) +
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
}
return CGFloat(curr.virtualStartMinutes)
}
prev = curr
}
return 0
}
func minutesOfDay(_ date: Date) -> Int {
let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
return (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
}
func trimTrailingPeriods(_ periods: [WidgetPeriod], lessons: [WidgetLesson]) -> [WidgetPeriod] {
guard let latestEnd = lessons.map({ minutesOfDay($0.end) }).max() else { return periods }
let lastUsed = periods.lastIndex { $0.startMinutes < latestEnd } ?? -1
return Array(periods.prefix(max(lastUsed + 1, MIN_VISIBLE_PERIODS)))
}
func totalVirtualMinutes(_ periods: [WidgetPeriod]) -> Int {
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
}
func resolveHourHeight(forGridHeight height: CGFloat, periods: [WidgetPeriod]) -> CGFloat {
let virtualHours = max(CGFloat(totalVirtualMinutes(periods)) / 60, 1)
return max(MIN_HOUR_HEIGHT, min(MAX_HOUR_HEIGHT, height / virtualHours))
}
/// Period boundaries deduped: adjacent periods share a line, periods on
/// either side of a break get their own (bracketing the break block).
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
var seen = Set<Int>()
var result: [Int] = []
for p in periods {
for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
if seen.insert(v).inserted { result.append(v) }
}
}
return result.sorted()
}
struct PeriodBreak: Hashable {
let virtualStart: Int
let virtualMinutes: Int
}
/// Only big breaks (Hofpause, Mittagspause) survive the mapper's small-break
/// collapse as a virtual gap between two periods.
func periodBreaks(_ periods: [WidgetPeriod]) -> [PeriodBreak] {
guard periods.count > 1 else { return [] }
return (0..<periods.count - 1).compactMap { i in
let gap = periods[i + 1].virtualStartMinutes - periods[i].virtualEndMinutes
return gap > 0
? PeriodBreak(virtualStart: periods[i].virtualEndMinutes, virtualMinutes: gap)
: nil
}
}
/// Font and line-box sizes shared by every tile of one widget, derived from
/// the height of a single period.
struct TileMetrics {
let maxBodyLines: Int
let bodyFont: CGFloat
let titleBox: CGFloat
let bodyBox: CGFloat
init(periods: [WidgetPeriod], hourHeight: CGFloat) {
let slotMinutes = periods.first.map { $0.virtualEndMinutes - $0.virtualStartMinutes }
?? FALLBACK_SLOT_MINUTES
let slotHeight = CGFloat(slotMinutes) * hourHeight / 60
let inner = slotHeight - LESSON_GAP - 2 * TILE_PADDING_V
func font(lines: CGFloat) -> CGFloat {
inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines))
}
maxBodyLines = font(lines: 2) < STACKED_BODY_MIN_FONT ? 1 : 2
bodyFont = max(MIN_BODY_FONT, min(MAX_BODY_FONT, font(lines: CGFloat(maxBodyLines))))
titleBox = bodyFont * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT
bodyBox = bodyFont * LINE_BOX_PER_FONT
}
}
/// Distributes the tile height like the in-app _TileContent: the title comes
/// first, then as many body lines as fit.
struct TileTextLayout {
let title: String
let titleBox: CGFloat
let titleFont: CGFloat
let titleLines: Int
let body: [String]
/// Set when there is no room for a line below the title: the room moves
/// next to it instead.
let inline: String?
/// Index of the line (0 = title row) that has to stay clear of the "+N"
/// badge, or nil when the text ends above it.
let badgeLine: Int?
init(lesson: WidgetLesson, innerHeight: CGFloat, innerWidth: CGFloat, metrics: TileMetrics) {
title = !lesson.subjectShort.isEmpty ? lesson.subjectShort : (lesson.subjectLong ?? "—")
let candidates = [lesson.room, lesson.teacher ?? lesson.originalTeacher]
.compactMap { $0 }
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
let singleTitleBox = min(metrics.titleBox, innerHeight)
let capacity = max(
0,
min(metrics.maxBodyLines, Int((innerHeight - singleTitleBox) / metrics.bodyBox + 0.05))
)
if capacity >= candidates.count {
body = candidates
} else if capacity == 1 {
// Rather share one line than drop the teacher entirely.
body = [candidates.joined(separator: " · ")]
} else {
body = []
}
if capacity == 0, let first = candidates.first, !title.contains(first) {
inline = first
} else {
inline = nil
}
let spare = innerHeight - singleTitleBox - CGFloat(body.count) * metrics.bodyBox
if spare >= singleTitleBox,
let wrappedLine = TileTextLayout.wrappedTitleLineBox(
title, innerWidth: innerWidth, metrics: metrics
) {
titleBox = 2 * wrappedLine
titleFont = wrappedLine / LINE_BOX_PER_FONT
titleLines = 2
} else {
titleBox = singleTitleBox
titleFont = singleTitleBox / LINE_BOX_PER_FONT
titleLines = 1
}
let content = titleBox + CGFloat(body.count) * metrics.bodyBox
let hasBadge = (lesson.siblingCount ?? 0) > 0
badgeLine = hasBadge && innerHeight - content < BADGE_HEIGHT ? body.count : nil
}
/// Line box for a title wrapped onto two lines, or nil when it should
/// stay on one: it fits there at a readable size, or it has no word
/// boundary to break at. The box is capped so the longest word still
/// fits the tile width.
private static func wrappedTitleLineBox(
_ title: String,
innerWidth: CGFloat,
metrics: TileMetrics
) -> CGFloat? {
func widthPerFontPoint(_ text: String) -> CGFloat {
let font = UIFont.systemFont(ofSize: 100, weight: .medium)
return (text as NSString).size(withAttributes: [.font: font]).width / 100
}
if widthPerFontPoint(title) * TITLE_WRAP_BELOW_FONT <= innerWidth { return nil }
let words = title
.replacingOccurrences(of: "-", with: "- ")
.split(separator: " ")
.map(String.init)
guard words.count > 1, let widest = words.map(widthPerFontPoint).max(), widest > 0 else {
return nil
}
return min(metrics.titleBox, innerWidth / widest * LINE_BOX_PER_FONT)
}
}
extension Color {
/// Parses the `#RRGGBB` tile colour resolved in Dart.
init?(widgetHex hex: String?) {
guard let hex, hex.count == 7, hex.hasPrefix("#"),
let value = UInt32(hex.dropFirst(), radix: 16) else {
return nil
}
self.init(
red: Double((value >> 16) & 0xFF) / 255,
green: Double((value >> 8) & 0xFF) / 255,
blue: Double(value & 0xFF) / 255
)
}
}
extension WidgetLesson {
var tileColor: Color {
if let resolved = Color(widgetHex: color) { return resolved }
// Only reached for payloads written before Dart shipped the resolved
// tile colour; mirrors lesson_color.dart.
switch status {
// No time-based highlight: the widget timeline only ticks every
// ~30min, so "current" and "past" would be stale most of the day.
case .regular, .past, .ongoing:
return Color(red: 153 / 255, green: 51 / 255, blue: 51 / 255)
case .cancelled: return .black
case .irregular: return Color(red: 143 / 255, green: 25 / 255, blue: 179 / 255)
case .teacherChanged: return Color(red: 41 / 255, green: 99 / 255, blue: 155 / 255)
case .duty: return Color(red: 0, green: 121 / 255, blue: 107 / 255)
case .event:
return customColor != nil
? Color(red: 1, green: 160 / 255, blue: 0)
: Color(red: 46 / 255, green: 125 / 255, blue: 50 / 255)
}
}
}
@@ -20,44 +20,36 @@ 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(days: days) dayHeaderRow(data: data)
.padding(.top, GRID_MARGIN_TOP)
GeometryReader { geo in GeometryReader { geo in
let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods) let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
HStack(alignment: .top, spacing: 0) { let hourHeight = max(
PeriodRuler(periods: periods, hourHeight: height) MIN_HOUR_HEIGHT,
columnDivider min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
ForEach(days.indices, id: \.self) { index in
TimeGridView(
lessons: weekLessons.filter {
cal.isDate($0.start, inSameDayAs: days[index])
},
periods: periods,
hourHeight: height,
holiday: holiday(on: days[index], data: data),
isToday: cal.isDate(days[index], inSameDayAs: entry.date)
) )
if index < 4 { columnDivider } 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) {
timeLabelsColumn(hourHeight: hourHeight, periods: data.periods)
.frame(width: 28, alignment: .topTrailing)
columnDivider
ForEach(0..<5, id: \.self) { offset in
column(
data: data,
offset: offset,
hourHeight: hourHeight,
subjectOnly: subjectOnly
)
.frame(maxWidth: .infinity)
if offset < 4 { columnDivider }
} }
} }
} }
.padding(.top, 2)
}
}
private func holiday(on day: Date, data: WidgetTimetableData) -> WidgetDayInfo? {
data.days?.first {
$0.isHoliday && Calendar.current.isDate($0.date, inSameDayAs: day)
} }
} }
@@ -71,55 +63,112 @@ 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(spacing: 6) { return HStack {
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))") Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
.font(.system(size: 14, weight: .bold)) .font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.65) .minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, alignment: .leading) Spacer()
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 10)) .font(.system(size: 9))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.fixedSize()
} }
.frame(height: TITLE_ROW_HEIGHT)
} }
/// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below, private func dayHeaderRow(data: WidgetTimetableData) -> some View {
/// today inside an accent circle. let cal = Calendar.current
private func dayHeaderRow(days: [Date]) -> some View { // `.frame(maxWidth: .infinity)` is critical: without an explicit
// greedy width (or a true `Spacer()` inside), this HStack collapses
// to the sum of its fixed-width children in a `.leading` VStack
// and the 5 day-columns end up sharing ~5pt of width — the labels
// crunch to the left and stop aligning with the grid columns below.
// The fixed height keeps `columnDivider`'s vertically-flexible // The fixed height keeps `columnDivider`'s vertically-flexible
// Rectangle from stealing space from the GeometryReader below. // Rectangle from stealing space from the GeometryReader.
HStack(spacing: 0) { return HStack(spacing: 0) {
Spacer().frame(width: RULER_WIDTH) Spacer().frame(width: 28)
columnDivider columnDivider
ForEach(days.indices, id: \.self) { index in ForEach(0..<5, id: \.self) { offset in
dayHeaderCell(days[index]) let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
VStack(spacing: -1) {
Text(weekday(for: day))
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textPrimary)
Text(shortDate(day))
.font(.system(size: 8))
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
if index < 4 { columnDivider } if offset < 4 { columnDivider }
} }
} }
.frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT) // 20pt fits 10pt weekday + 8pt date with tight -1pt spacing.
.frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20)
} }
private func dayHeaderCell(_ day: Date) -> some View { private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View {
// `entry.date`, not `Date()` — WidgetKit archives entries ahead of let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
// time, the midnight entry has to mark its own day. let totalHeight = CGFloat(totalMin) * hourHeight / 60.0
let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date) // Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked
return VStack(spacing: 1) { // time+name no longer fits in one period slot and the two labels
Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased()) // visually clip into the next slot. Drop to a single bold period
.font(.system(size: 9, weight: .medium)) // number until there's room to read both lines.
.foregroundStyle(isToday ? palette.accent : palette.textSecondary) let compact = hourHeight < 26
.lineLimit(1) return ZStack(alignment: .topTrailing) {
Text("\(Calendar.current.component(.day, from: day))") ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
.font(.system(size: 11, weight: isToday ? .bold : .regular)) Rectangle()
.foregroundStyle(isToday ? palette.onAccent : palette.textPrimary) .fill(palette.divider)
.lineLimit(1) .frame(height: 1)
.frame(width: 18, height: 18) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
.background(Circle().fill(isToday ? palette.accent : 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(
data: WidgetTimetableData,
offset: Int,
hourHeight: CGFloat,
subjectOnly: Bool
) -> some View {
let cal = Calendar.current
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
let lessonsForDay = data.lessons.filter { cal.isDate($0.start, inSameDayAs: day) }
return TimeGridView(
lessons: lessonsForDay,
periods: data.periods,
hourHeight: hourHeight,
showRoom: !subjectOnly,
showTeacher: !subjectOnly,
showTimeLabels: false,
horizontalPadding: 3
)
}
private func weekday(for date: Date) -> String {
WidgetDateFormatters.weekdayShort.string(from: date)
} }
private func placeholder(_ message: String) -> some View { private func placeholder(_ message: String) -> some View {
@@ -33,9 +33,6 @@ 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,27 +9,15 @@ 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),
accent: lightAccent, breakBlock: Color.black.opacity(0x0C / 255.0),
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
) )
@@ -38,11 +26,7 @@ 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),
accent: darkAccent, breakBlock: Color.white.opacity(0x14 / 255.0),
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
) )
-2
View File
@@ -1,2 +0,0 @@
app_identifier("eu.mhsl.marianum.mobile.client")
team_id("MY55VF3KPG")
-27
View File
@@ -1,27 +0,0 @@
default_platform(:ios)
# App-Store-Auslieferung über fastlane deliver. Die Screenshots erzeugt
# ../tool/screenshots_ios.sh (Demo-Login + integration_test) direkt in
# screenshots/de-DE/; deliver ordnet sie anhand der Pixelgröße iPhone 6,5″ bzw.
# iPad 13″ zu.
#
# Authentifizierung über einen App-Store-Connect-API-Key (JSON im fastlane-
# Format, siehe README.md). Pfad per APP_STORE_CONNECT_API_KEY setzen; die Datei
# selbst wird per .gitignore nicht eingecheckt.
platform :ios do
desc "Nur Screenshots hochladen (ersetzt die vorhandenen, verändert keine Texte/Binaries)"
lane :upload_screenshots do
upload_to_app_store(
api_key_path: ENV["APP_STORE_CONNECT_API_KEY"] || "fastlane/app-store-connect-api-key.json",
skip_binary_upload: true,
skip_metadata: true,
skip_app_version_update: true,
skip_screenshots: false,
overwrite_screenshots: true,
screenshots_path: "./fastlane/screenshots",
precheck_include_in_app_purchases: false,
run_precheck_before_submit: false,
force: true, # kein HTML-Vorschau-Bestätigungsschritt
)
end
end
-49
View File
@@ -1,49 +0,0 @@
# Fastlane – App-Store-Screenshots & Upload
iOS-Gegenstück zu `android/fastlane`. Die App muss dafür **nicht** neu gebaut
werden – der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte aus
Fixtures.
## 1. Screenshots aufnehmen
Aus dem **Client**-Wurzelverzeichnis:
```bash
tool/screenshots_ios.sh
```
Fährt „iPhone 14 Plus“ (6,5″, 1284×2778) und „iPad Pro 13-inch (M5)“
(13″, 2064×2752) nacheinander durch und legt die PNGs in
`materials/screenshots/{ios/6-5-inch,ipados/13-inch}/` sowie als JPEG-Upload-Kopie
(App Store Connect lehnt Alpha-Kanäle ab) in `screenshots/de-DE/` ab. Fehlende
Simulatoren legt das Skript selbst an.
- Nur eine Größe: `PROFILES="iphone" tool/screenshots_ios.sh`
- Andere Simulatoren: `IPHONE_SIM="iPhone 11 Pro Max" IPAD_SIM="…" tool/screenshots_ios.sh`
App und Schlüsselbund der verwendeten Simulatoren werden dabei zurückgesetzt.
## 2. Hochladen
fastlane installieren (`brew install fastlane`), in App Store Connect unter
*Benutzer und Zugriff → Integrationen → App Store Connect API* einen Key mit
Rolle *App-Manager* anlegen und als JSON ablegen:
```json
{
"key_id": "ABC123XYZ",
"issuer_id": "00000000-0000-0000-0000-000000000000",
"key": "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----",
"in_house": false
}
```
```bash
export APP_STORE_CONNECT_API_KEY=/pfad/zu/app-store-connect-api-key.json
cd ios
fastlane upload_screenshots
```
Die Screenshots landen in der aktuell bearbeitbaren App-Store-Version
(„Vorbereitung für Einreichung“); vorhandene Screenshots werden ersetzt. Der
API-Key wird **nicht** eingecheckt (siehe `.gitignore`).
Whitespace-only changes.
+17
View File
@@ -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));
}
+20
View File
@@ -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,
};
}
-5
View File
@@ -7,9 +7,4 @@ abstract class ApiResponse {
@JsonKey(includeIfNull: false) @JsonKey(includeIfNull: false)
Map<String, String>? headers; Map<String, String>? headers;
/// Entries the decoder skipped (see `decodeListLenient`), reported by the
/// API layer once the response is back on the main isolate.
@JsonKey(includeFromJson: false, includeToJson: false)
List<String> parseWarnings = const [];
} }
-225
View File
@@ -1,225 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../session/session_manager.dart';
import '../utils/directory_size.dart';
/// One cached response: the raw JSON payload and when it was stored.
class CacheEntry {
final String json;
final int lastUpdate;
const CacheEntry({required this.json, required this.lastUpdate});
}
/// File-per-key store behind [RequestCache] and friends.
///
/// Replaces `localstore` for response caching: localstore reads and decodes
/// the entire collection synchronously on first access, keeps every document
/// in memory for the whole session and JSON-encodes payloads a second time.
/// Here every access is async and touches exactly one file, and the payload is
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
///
/// Every account has its own directory; all access goes to the one of the
/// active account, in every isolate.
class CacheStore {
CacheStore._();
static final CacheStore instance = CacheStore._();
/// Payloads above this size are decoded on a background isolate.
static const int isolateDecodeThreshold = 64 * 1024;
static const _directoryPrefix = 'request_cache';
final Map<String, Future<Directory>> _dirs = {};
final Map<String, Future<void>> _writes = {};
/// Directory name for an account's storage [namespace]; null while no
/// account is active. The account from before multi-account support
/// (namespace '') keeps the original directory.
@visibleForTesting
static String directoryName(String? namespace) => switch (namespace) {
null => '$_directoryPrefix-signed-out',
'' => _directoryPrefix,
_ => '$_directoryPrefix-$namespace',
};
Future<Directory> _directory() {
final name = directoryName(SessionManager().activeAccount?.namespace);
return _dirs[name] ??= () async {
try {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
await dir.create(recursive: true);
return dir;
} on Object {
// Not memoized (disk full at launch): the next call tries again.
unawaited(_dirs.remove(name));
rethrow;
}
}();
}
Future<File> _file(String key) async =>
File('${(await _directory()).path}/${_safeName(key)}');
/// Keys are internal ids (`nc-chat-<token>`, `wd-folder-<md5>`); only path
/// separators would be a problem in a file name.
static String _safeName(String key) => key.replaceAll(RegExp(r'[/\\]'), '_');
Future<CacheEntry?> read(String key) async {
try {
final file = await _file(key);
await _writes[file.path];
return parse(await file.readAsString());
} on Object {
// Missing (PathNotFoundException) or unreadable: a cache miss.
return null;
}
}
/// All entries whose key starts with [prefix], read concurrently.
Future<Map<String, CacheEntry>> readAll({String prefix = ''}) async {
final keys = await this.keys(prefix: prefix);
final entries = await Future.wait(keys.map(read));
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
}
Future<void> write(String key, String json) async {
// Resolved up front: a queued write must not follow an account switch
// into the next account's directory.
final file = await _file(key);
final path = file.path;
final previous = _writes[path] ?? Future<void>.value();
late final Future<void> current;
current = previous.then((_) => _write(file, json)).whenComplete(() {
if (identical(_writes[path], current)) _writes.remove(path);
});
return _writes[path] = current;
}
Future<void> _write(File file, String json) async {
try {
// Write-then-rename so a reader (or the widget background isolate)
// never sees a half-written file.
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(
'${DateTime.now().millisecondsSinceEpoch}\n$json',
flush: true,
);
await tmp.rename(file.path);
} on Object catch (e) {
debugPrint('CacheStore.write(${file.path}) failed: $e');
}
}
Future<void> delete(String key) async {
try {
final file = await _file(key);
await _writes[file.path];
await file.delete();
} on Object {
// A missing or locked file is as good as deleted for a cache.
}
}
/// Empties the active account's cache.
Future<void> clear() async {
try {
final dir = await _directory();
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.clear failed: $e');
}
_dirs.clear();
}
/// Removes the cache of the account with [namespace], active or not.
Future<void> deleteNamespace(String namespace) async {
final name = directoryName(namespace);
try {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.deleteNamespace failed: $e');
}
_dirs.removeWhere((key, _) => key == name);
}
/// All stored keys, optionally filtered by [prefix].
Future<List<String>> keys({String prefix = ''}) async {
try {
final dir = await _directory();
return [
await for (final entity in dir.list())
if (entity is File && !entity.path.endsWith('.tmp'))
if (entity.uri.pathSegments.last case final name
when name.startsWith(prefix))
name,
];
} on Object {
return const [];
}
}
/// Removes entries not written for [maxAge] from every account's cache,
/// judged by file mtime so nothing has to be read or decoded.
Future<void> deleteOlderThan(Duration maxAge) async {
try {
final base = await getApplicationCacheDirectory();
final cutoff = DateTime.now().subtract(maxAge);
await for (final dir in base.list()) {
if (dir is! Directory) continue;
final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
if (!name.startsWith(_directoryPrefix)) continue;
await for (final entity in dir.list()) {
if (entity is! File) continue;
// ignore: avoid_slow_async_io
final modified = (await entity.stat()).modified;
if (modified.isBefore(cutoff)) await entity.delete();
}
}
} on Object catch (e) {
debugPrint('CacheStore.deleteOlderThan failed: $e');
}
}
/// Total size of all entries in bytes.
Future<int> totalSize() async => directorySize(await _directory());
/// Parses the stored file format; public for tests.
static CacheEntry? parse(String raw) {
final newline = raw.indexOf('\n');
if (newline < 0) return null;
final lastUpdate = int.tryParse(raw.substring(0, newline));
if (lastUpdate == null) return null;
return CacheEntry(json: raw.substring(newline + 1), lastUpdate: lastUpdate);
}
/// Decodes a cached payload, off the UI isolate when it is large.
static Future<Map<String, dynamic>> decode(String json) =>
json.length > isolateDecodeThreshold
? compute(_decodeMap, json)
: Future.value(_decodeMap(json));
static Map<String, dynamic> _decodeMap(String json) =>
jsonDecode(json) as Map<String, dynamic>;
/// Deletes the directory the old localstore-based cache lived in. It is
/// never read again and would otherwise linger with up to 200 days of data.
static Future<void> deleteLegacyLocalstoreCache() async {
try {
final docs = await getApplicationDocumentsDirectory();
final legacy = Directory('${docs.path}/MarianumMobile');
if (legacy.existsSync()) await legacy.delete(recursive: true);
} on Object catch (e) {
debugPrint('Legacy cache cleanup failed: $e');
}
}
}
+23
View File
@@ -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
+273
View File
@@ -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(),
),
];
}
}
-28
View File
@@ -1,28 +0,0 @@
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import '../../model/endpoint_data.dart';
import '../errors/network_exception.dart';
import 'demo_mode.dart';
/// Demo sessions carry made-up credentials. Every request they sent to the
/// real Nextcloud would count as a failed login there and, in bulk, trip the
/// server's brute-force throttle for the whole IP (HTTP 429 for everyone
/// behind it). Installed app-wide via [http.runWithClient].
class DemoHttpClient extends http.BaseClient {
final http.Client _inner = IOClient();
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
if (DemoMode.active && isNextcloudHost(request.url)) {
return Future.error(NetworkException.demoBlocked(request.url));
}
return _inner.send(request);
}
@override
void close() => _inner.close();
static bool isNextcloudHost(Uri url) =>
url.host == EndpointData().nextcloud().domain;
}
-2
View File
@@ -32,8 +32,6 @@ class DemoMarianumConnect {
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList(); return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
case 'timetable/holidays': case 'timetable/holidays':
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList(); return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
case 'timetable/custom-events':
return DemoTimetable.customEvents().toJson();
case 'holidays': case 'holidays':
return DemoHolidays.upcoming().map((e) => e.toJson()).toList(); return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
case 'users/search': case 'users/search':
+8
View File
@@ -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 => SessionManager().isDemo; static bool get active => SessionManager().isDemo;
} }
+12 -52
View File
@@ -10,7 +10,9 @@ import '../api_error.dart';
import '../http_errors.dart'; import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart'; import '../marianumcloud/talk/talk_error.dart';
import 'app_exception.dart'; import 'app_exception.dart';
import 'auth_exception.dart';
import 'network_exception.dart'; import 'network_exception.dart';
import 'not_found_exception.dart';
import 'parse_exception.dart'; import 'parse_exception.dart';
import 'server_exception.dart'; import 'server_exception.dart';
import 'talk_exception.dart'; import 'talk_exception.dart';
@@ -54,74 +56,32 @@ AppException? _dioToAppException(DioException error) {
} }
} }
final RegExp _davException = RegExp(
r'<s:exception>\s*([^<]*?)\s*</s:exception>',
);
final RegExp _davMessage = RegExp(r'<s:message>\s*([^<]*?)\s*</s:message>');
/// The Sabre DAV error body of a failed WebDAV request: the exception class
/// (without its `Sabre\DAV\Exception\` namespace) and the server's message.
/// The class says what actually went wrong where the status is ambiguous —
/// MKCOL on an existing folder is a plain 405.
({String exception, String message})? parseDavError(String body) {
final exception = _davException.firstMatch(body)?.group(1);
if (exception == null || exception.isEmpty) return null;
return (
exception: exception.split('\\').last,
message: _davMessage.firstMatch(body)?.group(1) ?? '',
);
}
AppException? _davToAppException(
int status,
({String exception, String message}) dav,
String detail,
) {
final userMessage = switch (dav.exception) {
'MethodNotAllowed' when status == 405 =>
'Ein Ordner oder eine Datei mit diesem Namen existiert bereits.',
'PreconditionFailed' =>
'Am Zielort existiert bereits ein Eintrag mit diesem Namen.',
'Conflict' => 'Der übergeordnete Ordner existiert nicht mehr.',
'Locked' =>
'Der Eintrag wird gerade bearbeitet. Bitte versuche es gleich erneut.',
'InsufficientStorage' =>
'Der verfügbare Speicherplatz reicht dafür nicht aus.',
_ => null,
};
if (userMessage == null) return null;
return ServerException(
statusCode: status,
userMessage: userMessage,
technicalDetails: detail,
allowRetry: dav.exception == 'Locked',
);
}
/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx. /// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx.
/// Its toString() dumps every response header, so the details keep only the /// Its toString() dumps every response header, so the details keep only the
/// status plus a trimmed body preview (same format as the Talk API errors). /// status plus a trimmed body preview (same format as the Talk API errors).
AppException _dynamiteToAppException(DynamiteApiException error) { AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode; final status = error.statusCode;
final dav = parseDavError(error.body);
if (dav != null) {
final detail = 'HTTP $status ${dav.exception}: ${dav.message}';
return _davToAppException(status, dav, detail) ??
exceptionForStatus(status, detail);
}
final preview = previewBody(error.body); final preview = previewBody(error.body);
final detail = preview.isEmpty final detail = preview.isEmpty
? 'HTTP $status' ? 'HTTP $status'
: 'HTTP $status body=$preview'; : 'HTTP $status body=$preview';
if (status == 429) { switch (status) {
case 401:
return AuthException.unauthorized(technicalDetails: detail);
case 403:
return AuthException.forbidden(technicalDetails: detail);
case 404:
return NotFoundException(technicalDetails: detail);
case 429:
return ServerException( return ServerException(
statusCode: status, statusCode: status,
userMessage: userMessage:
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.', 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
technicalDetails: detail, technicalDetails: detail,
); );
default:
return ServerException(statusCode: status, technicalDetails: detail);
} }
return exceptionForStatus(status, detail);
} }
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
-6
View File
@@ -13,10 +13,4 @@ class NetworkException extends AppException {
'Der Server hat zu lange gebraucht. Bitte versuche es erneut.', 'Der Server hat zu lange gebraucht. Bitte versuche es erneut.',
technicalDetails: technicalDetails, technicalDetails: technicalDetails,
); );
/// A request a demo session must never send (see `DemoHttpClient`).
factory NetworkException.demoBlocked(Uri url) => NetworkException(
userMessage: 'Im Demo-Modus nicht verfügbar.',
technicalDetails: 'demo mode blocked $url',
);
} }
+1 -1
View File
@@ -7,7 +7,6 @@ class ServerException extends AppException {
required this.statusCode, required this.statusCode,
String? userMessage, String? userMessage,
super.technicalDetails, super.technicalDetails,
super.allowRetry,
}) : super( }) : super(
// Status code intentionally not in the user message — it lives in // Status code intentionally not in the user message — it lives in
// technicalDetails behind "Details anzeigen". // technicalDetails behind "Details anzeigen".
@@ -15,5 +14,6 @@ class ServerException extends AppException {
userMessage ?? userMessage ??
'Der Server konnte die Anfrage gerade nicht verarbeiten. ' 'Der Server konnte die Anfrage gerade nicht verarbeiten. '
'Bitte versuche es in einem Moment erneut.', 'Bitte versuche es in einem Moment erneut.',
allowRetry: true,
); );
} }
@@ -1,11 +0,0 @@
import 'app_exception.dart';
/// A request finished after the account it was started for signed out. Its
/// result must not reach the state or cache of whoever is signed in now.
class StaleSessionException extends AppException {
const StaleSessionException()
: super(
userMessage: 'Die Anmeldung hat sich geändert. Bitte lade neu.',
technicalDetails: 'result of a previous session discarded',
);
}
+8 -20
View File
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'errors/app_exception.dart';
import 'errors/auth_exception.dart'; import 'errors/auth_exception.dart';
import 'errors/network_exception.dart'; import 'errors/network_exception.dart';
import 'errors/not_found_exception.dart'; import 'errors/not_found_exception.dart';
@@ -43,23 +42,12 @@ String httpErrorDetail(String label, String body, int status) {
: '$label -> HTTP $status body=$preview'; : '$label -> HTTP $status body=$preview';
} }
/// The [AppException] matching a non-2xx HTTP [status], carrying [detail] as /// Throws the [AppException] matching a non-2xx HTTP [status], carrying
/// technical details: 401/403 map to auth errors, 404 to not-found, everything /// [detail] as technical details: 401/403 map to auth errors, 404 to
/// else to a generic server error. /// not-found, everything else to a generic server error.
AppException exceptionForStatus(int status, String detail) => switch (status) { Never throwForStatus(int status, String detail) {
401 => AuthException.unauthorized(technicalDetails: detail), if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
403 => AuthException.forbidden(technicalDetails: detail), if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
404 => NotFoundException(technicalDetails: detail), if (status == 404) throw NotFoundException(technicalDetails: detail);
_ => ServerException(statusCode: status, technicalDetails: detail), throw ServerException(statusCode: status, technicalDetails: detail);
};
/// Throws [exceptionForStatus].
Never throwForStatus(int status, String detail) =>
throw exceptionForStatus(status, detail);
/// Throws [exceptionForStatus] unless [response] is 2xx.
void ensureSuccess(String label, http.Response response) {
final status = response.statusCode;
if (status >= 200 && status < 300) return;
throwForStatus(status, httpErrorDetail(label, response.body, status));
} }
-46
View File
@@ -1,46 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
/// Decodes server lists element by element so one entry the model cannot map
/// (a field the server dropped, an enum value it added) is skipped and
/// recorded instead of failing the whole response and with it the screen.
///
/// Skipped entries are collected per isolate; the API layer drains them with
/// [drainDecodeWarnings] and reports them, so a drift in the server's format
/// still reaches us without taking the feature down for the user.
final List<String> _warnings = [];
List<T> decodeListLenient<T>(
Object? raw,
T Function(Map<String, dynamic>) fromJson, {
required String what,
}) {
final items = <T>[];
if (raw is! List<dynamic>) {
throw FormatException(
'expected a list of ${what}s, got ${raw.runtimeType}',
);
}
for (final entry in raw) {
try {
items.add(fromJson(entry as Map<String, dynamic>));
} on CheckedFromJsonException catch (e) {
_warnings.add('$what: $e');
} on TypeError catch (e) {
_warnings.add('$what: $e');
}
}
return items;
}
Set<T> decodeSetLenient<T>(
Object? raw,
T Function(Map<String, dynamic>) fromJson, {
required String what,
}) => decodeListLenient(raw, fromJson, what: what).toSet();
/// Returns and clears the entries skipped since the last call.
List<String> drainDecodeWarnings() {
final out = List<String>.of(_warnings);
_warnings.clear();
return out;
}
@@ -18,7 +18,13 @@ class DeleteAppPassword {
NextcloudOcs.uri('core/apppassword'), NextcloudOcs.uri('core/apppassword'),
// An explicit header may belong to an inactive account, so the active // An explicit header may belong to an inactive account, so the active
// session's headers must not be required then. // session's headers must not be required then.
headers: NextcloudOcs.headers(authorization: authorizationHeader), headers: authorizationHeader == null
? NextcloudOcs.headers()
: {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'Authorization': authorizationHeader,
},
); );
} }
} }
@@ -28,17 +28,23 @@ class GetAppPassword {
label, label,
() => _client.get( () => _client.get(
NextcloudOcs.uri('core/getapppassword'), NextcloudOcs.uri('core/getapppassword'),
headers: {
...NextcloudOcs.headers(),
// 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.
headers: NextcloudOcs.headers( 'Authorization': SessionManager()
authorization: SessionManager()
.requireNextcloud() .requireNextcloud()
.realPasswordBasicAuthHeader, .realPasswordBasicAuthHeader,
), },
), ),
))!; ))!;
ensureSuccess(label, response); if (response.statusCode < 200 || response.statusCode >= 300) {
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
final json = jsonDecode(utf8.decode(response.bodyBytes)); final json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data']; final data = (json as Map)['ocs']?['data'];
final appPassword = data is Map ? data['apppassword'] as String? : null; final appPassword = data is Map ? data['apppassword'] as String? : null;
@@ -4,9 +4,6 @@ import 'package:http/http.dart' as http;
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../errors/server_exception.dart'; import '../../errors/server_exception.dart';
import '../../http_errors.dart';
import '../../json_lenient.dart';
import '../../marianumconnect/queries/report_client_error/client_error_reporter.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
import 'autocomplete_response.dart'; import 'autocomplete_response.dart';
@@ -33,25 +30,13 @@ class AutocompleteApi {
'limit': '10', 'limit': '10',
}, },
); );
final response = (await sendGuarded( final response = await http.get(uri, headers: NextcloudOcs.headers());
'Autocomplete $uri',
() => http
.get(uri, headers: NextcloudOcs.headers())
.timeout(const Duration(seconds: 20)),
))!;
if (response.statusCode != HttpStatus.ok) { if (response.statusCode != HttpStatus.ok) {
throw ServerException( throw ServerException(
statusCode: response.statusCode, statusCode: response.statusCode,
technicalDetails: 'core/autocomplete/get: ${response.body}', technicalDetails: 'core/autocomplete/get: ${response.body}',
); );
} }
final result = AutocompleteResponse.fromJson( return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
NextcloudOcs.decode(response.body),
);
ClientErrorReporter.reportDecodeWarnings(
'Autocomplete',
drainDecodeWarnings(),
);
return result;
} }
} }
@@ -1,6 +1,5 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import '../../json_lenient.dart';
import '../files_sharing/queries/share/share.dart'; import '../files_sharing/queries/share/share.dart';
part 'autocomplete_response.g.dart'; part 'autocomplete_response.g.dart';
@@ -15,7 +14,6 @@ int shareTypeFromSource(String? source) {
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class AutocompleteResponse { class AutocompleteResponse {
@JsonKey(fromJson: _entriesFromJson)
List<AutocompleteResponseObject> data; List<AutocompleteResponseObject> data;
AutocompleteResponse(this.data); AutocompleteResponse(this.data);
@@ -25,31 +23,15 @@ class AutocompleteResponse {
Map<String, dynamic> toJson() => _$AutocompleteResponseToJson(this); Map<String, dynamic> toJson() => _$AutocompleteResponseToJson(this);
} }
List<AutocompleteResponseObject> _entriesFromJson(Object? raw) =>
decodeListLenient(
raw,
AutocompleteResponseObject.fromJson,
what: 'autocomplete entry',
);
/// The server sends a user-status object for users that have one and an empty
/// string otherwise; only the status string itself is kept.
String? _statusFromJson(Object? raw) => switch (raw) {
Map<String, dynamic> status => status['status']?.toString(),
String status when status.isNotEmpty => status,
_ => null,
};
@JsonSerializable() @JsonSerializable()
class AutocompleteResponseObject { class AutocompleteResponseObject {
String id; String id;
String label; String label;
String? icon; String? icon;
String? source; String? source;
@JsonKey(fromJson: _statusFromJson)
String? status; String? status;
String? subline; String? subline;
String? shareWithDisplayNameUnique; String? shareWithDisplayNameUniqe;
AutocompleteResponseObject( AutocompleteResponseObject(
this.id, this.id,
@@ -58,7 +40,7 @@ class AutocompleteResponseObject {
this.source, this.source,
this.status, this.status,
this.subline, this.subline,
this.shareWithDisplayNameUnique, this.shareWithDisplayNameUniqe,
); );
factory AutocompleteResponseObject.fromJson(Map<String, dynamic> json) => factory AutocompleteResponseObject.fromJson(Map<String, dynamic> json) =>
@@ -8,12 +8,13 @@ part of 'autocomplete_response.dart';
AutocompleteResponse _$AutocompleteResponseFromJson( AutocompleteResponse _$AutocompleteResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('AutocompleteResponse', json, ($checkedConvert) { ) => AutocompleteResponse(
final val = AutocompleteResponse( (json['data'] as List<dynamic>)
$checkedConvert('data', (v) => _entriesFromJson(v)), .map(
(e) => AutocompleteResponseObject.fromJson(e as Map<String, dynamic>),
)
.toList(),
); );
return val;
});
Map<String, dynamic> _$AutocompleteResponseToJson( Map<String, dynamic> _$AutocompleteResponseToJson(
AutocompleteResponse instance, AutocompleteResponse instance,
@@ -21,18 +22,15 @@ Map<String, dynamic> _$AutocompleteResponseToJson(
AutocompleteResponseObject _$AutocompleteResponseObjectFromJson( AutocompleteResponseObject _$AutocompleteResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('AutocompleteResponseObject', json, ($checkedConvert) { ) => AutocompleteResponseObject(
final val = AutocompleteResponseObject( json['id'] as String,
$checkedConvert('id', (v) => v as String), json['label'] as String,
$checkedConvert('label', (v) => v as String), json['icon'] as String?,
$checkedConvert('icon', (v) => v as String?), json['source'] as String?,
$checkedConvert('source', (v) => v as String?), json['status'] as String?,
$checkedConvert('status', (v) => _statusFromJson(v)), json['subline'] as String?,
$checkedConvert('subline', (v) => v as String?), json['shareWithDisplayNameUniqe'] as String?,
$checkedConvert('shareWithDisplayNameUnique', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$AutocompleteResponseObjectToJson( Map<String, dynamic> _$AutocompleteResponseObjectToJson(
AutocompleteResponseObject instance, AutocompleteResponseObject instance,
@@ -43,5 +41,5 @@ Map<String, dynamic> _$AutocompleteResponseObjectToJson(
'source': instance.source, 'source': instance.source,
'status': instance.status, 'status': instance.status,
'subline': instance.subline, 'subline': instance.subline,
'shareWithDisplayNameUnique': instance.shareWithDisplayNameUnique, 'shareWithDisplayNameUniqe': instance.shareWithDisplayNameUniqe,
}; };
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:typed_data'; import 'dart:typed_data';
@@ -95,8 +96,9 @@ class GetUserInfo {
uri, uri,
); );
try { try {
final root = jsonDecode(response.body) as Map<String, dynamic>;
final data = final data =
NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>; (root['ocs'] as Map<String, dynamic>)['data'] 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?) ?? '',
@@ -8,19 +8,16 @@ part of 'file_sharing_api_params.dart';
FileSharingApiParams _$FileSharingApiParamsFromJson( FileSharingApiParams _$FileSharingApiParamsFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('FileSharingApiParams', json, ($checkedConvert) { ) => FileSharingApiParams(
final val = FileSharingApiParams( shareType: (json['shareType'] as num).toInt(),
shareType: $checkedConvert('shareType', (v) => (v as num).toInt()), shareWith: json['shareWith'] as String,
shareWith: $checkedConvert('shareWith', (v) => v as String), path: json['path'] as String,
path: $checkedConvert('path', (v) => v as String), referenceId: json['referenceId'] as String?,
referenceId: $checkedConvert('referenceId', (v) => v as String?), talkMetaData: json['talkMetaData'] as String?,
talkMetaData: $checkedConvert('talkMetaData', (v) => v as String?), permissions: (json['permissions'] as num?)?.toInt(),
permissions: $checkedConvert('permissions', (v) => (v as num?)?.toInt()), password: json['password'] as String?,
password: $checkedConvert('password', (v) => v as String?), expireDate: json['expireDate'] as String?,
expireDate: $checkedConvert('expireDate', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$FileSharingApiParamsToJson( Map<String, dynamic> _$FileSharingApiParamsToJson(
FileSharingApiParams instance, FileSharingApiParams instance,
@@ -29,7 +29,12 @@ class LoginFlowApi {
headers: {'Accept': 'application/json', 'User-Agent': userAgent}, headers: {'Accept': 'application/json', 'User-Agent': userAgent},
), ),
))!; ))!;
ensureSuccess(label, response); if (response.statusCode < 200 || response.statusCode >= 300) {
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
return LoginFlowInit.fromJson( return LoginFlowInit.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>, jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
); );
@@ -48,7 +53,12 @@ class LoginFlowApi {
), ),
))!; ))!;
if (response.statusCode == 404) return null; if (response.statusCode == 404) return null;
ensureSuccess(label, response); if (response.statusCode < 200 || response.statusCode >= 300) {
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
return LoginFlowCredentials.fromJson( return LoginFlowCredentials.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>, jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
); );
+2 -5
View File
@@ -13,13 +13,10 @@ class NextcloudOcs {
static Map<String, dynamic> decode(String raw) => static Map<String, dynamic> decode(String raw) =>
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>; (jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
/// [authorization] overrides the active session's credentials, which are static Map<String, String> headers() => {
/// then not required.
static Map<String, String> headers({String? authorization}) => {
'Accept': 'application/json', 'Accept': 'application/json',
'OCS-APIRequest': 'true', 'OCS-APIRequest': 'true',
'Authorization': 'Authorization': SessionManager().requireNextcloud().basicAuthHeader,
authorization ?? SessionManager().requireNextcloud().basicAuthHeader,
}; };
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) { static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
@@ -1,37 +0,0 @@
import 'dart:io';
import 'package:http/http.dart' as http;
import '../nextcloud_ocs.dart';
import 'search_chat_messages_response.dart';
/// Wraps Talk's `talk-message-current` search provider, which searches the
/// whole server-side history of one conversation. The conversation is picked
/// via `from`, the route the provider would be opened from in the web UI.
class SearchChatMessages {
Future<SearchChatMessagesResponse> run({
required String chatToken,
required String term,
int limit = 25,
Object? cursor,
}) async {
final endpoint = NextcloudOcs.uri(
'search/providers/talk-message-current/search',
queryParameters: {
'term': term,
'from': '/call/$chatToken',
'limit': limit.toString(),
if (cursor != null) 'cursor': cursor.toString(),
},
);
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
if (response.statusCode != HttpStatus.ok) {
throw Exception(
'Chat search failed with ${response.statusCode}: ${response.body}',
);
}
final data =
NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
return SearchChatMessagesResponse.fromJson(data, term: term);
}
}
@@ -1,94 +0,0 @@
/// Parsed page of Talk's `talk-message-current` search provider.
class SearchChatMessagesResponse {
final List<SearchChatMessagesEntry> entries;
/// Opaque pagination cursor; `null` when the provider has no further page.
final Object? cursor;
const SearchChatMessagesResponse({required this.entries, this.cursor});
factory SearchChatMessagesResponse.fromJson(
Map<String, dynamic> json, {
required String term,
}) {
final rawEntries = json['entries'];
final entries = rawEntries is List
? rawEntries
.whereType<Map<String, dynamic>>()
.map((e) => SearchChatMessagesEntry.fromJson(e, term: term))
.whereType<SearchChatMessagesEntry>()
.toList()
: const <SearchChatMessagesEntry>[];
final paginated = json['isPaginated'] == true;
final cursor = json['cursor'];
return SearchChatMessagesResponse(
entries: entries,
cursor: paginated && entries.isNotEmpty && cursor != null ? cursor : null,
);
}
}
class SearchChatMessagesEntry {
final int messageId;
final String? actorId;
final String? actorDisplayName;
/// Server-built excerpt of the message, placeholders already resolved.
final String excerpt;
final DateTime? sentAt;
const SearchChatMessagesEntry({
required this.messageId,
required this.excerpt,
this.actorId,
this.actorDisplayName,
this.sentAt,
});
/// Returns `null` for entries without a recognisable message id.
static SearchChatMessagesEntry? fromJson(
Map<String, dynamic> json, {
required String term,
}) {
final attributes = json['attributes'];
final attrs = attributes is Map ? attributes : const {};
final messageId =
_parseInt(attrs['messageId']) ??
_parseInt(
RegExp(
r'#message_(\d+)',
).firstMatch(json['resourceUrl']?.toString() ?? '')?.group(1),
);
if (messageId == null) return null;
// Title and subline have swapped roles across Talk versions (author vs.
// excerpt); the one containing the search term is the excerpt.
final title = json['title']?.toString() ?? '';
final subline = json['subline']?.toString() ?? '';
final q = term.trim().toLowerCase();
final titleIsExcerpt =
q.isNotEmpty &&
title.toLowerCase().contains(q) &&
!subline.toLowerCase().contains(q);
final excerpt = titleIsExcerpt ? title : subline;
final author = titleIsExcerpt ? subline : title;
final timestamp = _parseInt(attrs['timestamp']);
final actorId = attrs['actorId']?.toString();
return SearchChatMessagesEntry(
messageId: messageId,
excerpt: excerpt,
actorId: actorId == null || actorId.isEmpty ? null : actorId,
actorDisplayName: author.isEmpty ? null : author,
sentAt: timestamp == null
? null
: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
);
}
static int? _parseInt(Object? raw) => switch (raw) {
final int v => v,
final String v => int.tryParse(v),
_ => null,
};
}
@@ -7,20 +7,14 @@ part of 'search_files_response.dart';
// ************************************************************************** // **************************************************************************
SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) => SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('SearchFilesResponse', json, ($checkedConvert) { SearchFilesResponse(
final val = SearchFilesResponse( name: json['name'] as String,
name: $checkedConvert('name', (v) => v as String), isPaginated: json['isPaginated'] as bool,
isPaginated: $checkedConvert('isPaginated', (v) => v as bool), cursor: (json['cursor'] as num?)?.toInt(),
cursor: $checkedConvert('cursor', (v) => (v as num?)?.toInt()), entries: (json['entries'] as List<dynamic>)
entries: $checkedConvert(
'entries',
(v) => (v as List<dynamic>)
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>)) .map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
),
); );
return val;
});
Map<String, dynamic> _$SearchFilesResponseToJson( Map<String, dynamic> _$SearchFilesResponseToJson(
SearchFilesResponse instance, SearchFilesResponse instance,
@@ -32,19 +26,13 @@ Map<String, dynamic> _$SearchFilesResponseToJson(
}; };
SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) => SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) =>
$checkedCreate('SearchFilesEntry', json, ($checkedConvert) { SearchFilesEntry(
final val = SearchFilesEntry( title: json['title'] as String,
title: $checkedConvert('title', (v) => v as String), subline: json['subline'] as String?,
subline: $checkedConvert('subline', (v) => v as String?), icon: json['icon'] as String?,
icon: $checkedConvert('icon', (v) => v as String?), resourceUrl: json['resourceUrl'] as String?,
resourceUrl: $checkedConvert('resourceUrl', (v) => v as String?), attributes: json['attributes'] as Map<String, dynamic>?,
attributes: $checkedConvert(
'attributes',
(v) => v as Map<String, dynamic>?,
),
); );
return val;
});
Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) => Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -4,7 +4,6 @@ import 'package:http/http.dart' as http;
import '../../../api_params.dart'; import '../../../api_params.dart';
import '../../../api_response.dart'; import '../../../api_response.dart';
import '../room/get_room_response.dart';
import '../talk_api.dart'; import '../talk_api.dart';
/// Small POST/DELETE-only Talk endpoints that have no response payload. /// Small POST/DELETE-only Talk endpoints that have no response payload.
@@ -106,21 +105,3 @@ class DeleteRoomAvatar extends TalkApi {
Map<String, String>? headers, Map<String, String>? headers,
) => http.delete(uri, headers: headers); ) => http.delete(uri, headers: headers);
} }
class SetNotificationLevel extends TalkApi {
final String chatToken;
final GetRoomResponseObjectParticipantNotificationLevel level;
SetNotificationLevel(this.chatToken, this.level)
: super('v4/room/$chatToken/notify', null);
@override
ApiResponse? assemble(String raw) => null;
@override
Future<http.Response> request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.post(uri, headers: headers, body: {'level': '${level.apiValue}'});
}
@@ -1,46 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// GETs a chat page outside [TalkApi], which treats non-2xx as errors: here
/// HTTP 304 (nothing new / no older messages) is a normal outcome and yields
/// `null`.
Future<GetChatResponse?> fetchChatPage({
required String label,
required String chatToken,
required GetChatParams params,
Duration? timeout,
bool parseOffIsolate = false,
}) async {
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded('$label $uri', () {
final request = http.get(uri, headers: headers);
return timeout == null ? request : request.timeout(timeout);
}))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final parsed = parseOffIsolate
? await compute(_parseChatResponse, response.body)
: _parseChatResponse(response.body);
return parsed..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: '$label $uri: HTTP $status',
);
}
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
@@ -1,3 +1,6 @@
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
@@ -13,4 +16,11 @@ class GetChat extends TalkApi<GetChatResponse> {
@override @override
GetChatResponse assemble(String raw) => GetChatResponse assemble(String raw) =>
GetChatResponse.fromJson(NextcloudOcs.decode(raw)); GetChatResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -17,7 +17,7 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
lookIntoFuture: GetChatParamsSwitch.off, lookIntoFuture: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on, setReadMarker: GetChatParamsSwitch.on,
// Small initial page; also the per-chat offline snapshot written to // Small initial page; also the per-chat offline snapshot written to
// the request cache. Older messages are paged in on scroll-up via // localstore. Older messages are paged in on scroll-up via
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize. // GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
limit: 50, limit: 50,
), ),
@@ -1,12 +1,16 @@
import 'fetch_chat_page.dart'; import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
import 'get_chat_response.dart'; import 'get_chat_response.dart';
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`) /// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
/// that fetches the page of messages *older* than a given id. The server /// that fetches the page of messages *older* than a given id. Bypasses [TalkApi]
/// answers HTTP 304 when there are no older messages left — a normal "start of /// because that layer treats non-2xx as errors, and the server answers HTTP 304
/// chat" outcome (see [fetchChatPage]). `setReadMarker=off` so paging into /// when there are no older messages left — a normal "start of chat" outcome here.
/// history never moves the read cursor. /// `setReadMarker=off` so paging into history never moves the read cursor.
class GetChatHistory { class GetChatHistory {
final String chatToken; final String chatToken;
final int lastKnownMessageId; final int lastKnownMessageId;
@@ -19,18 +23,34 @@ class GetChatHistory {
}); });
/// Returns the older page, or `null` on HTTP 304 (no older messages). /// Returns the older page, or `null` on HTTP 304 (no older messages).
Future<GetChatResponse?> run() => fetchChatPage( Future<GetChatResponse?> run() async {
label: 'GetChatHistory', final params = GetChatParams(
chatToken: chatToken,
params: GetChatParams(
lookIntoFuture: GetChatParamsSwitch.off, lookIntoFuture: GetChatParamsSwitch.off,
lastKnownMessageId: lastKnownMessageId, lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off, includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.off, setReadMarker: GetChatParamsSwitch.off,
limit: limit, limit: limit,
), );
// A page holds up to 200 messages and lands while the user is scrolling; final uri = NextcloudOcs.uri(
// decoding it on the UI isolate stalls the fling. 'apps/spreed/api/v1/chat/$chatToken',
parseOffIsolate: true, queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'GetChatHistory $uri',
() => http.get(uri, headers: headers),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'GetChatHistory $uri: HTTP $status',
); );
} }
}
@@ -7,33 +7,24 @@ part of 'get_chat_params.dart';
// ************************************************************************** // **************************************************************************
GetChatParams _$GetChatParamsFromJson(Map<String, dynamic> json) => GetChatParams _$GetChatParamsFromJson(Map<String, dynamic> json) =>
$checkedCreate('GetChatParams', json, ($checkedConvert) { GetChatParams(
final val = GetChatParams( lookIntoFuture: $enumDecode(
lookIntoFuture: $checkedConvert( _$GetChatParamsSwitchEnumMap,
'lookIntoFuture', json['lookIntoFuture'],
(v) => $enumDecode(_$GetChatParamsSwitchEnumMap, v),
), ),
limit: $checkedConvert('limit', (v) => (v as num?)?.toInt()), limit: (json['limit'] as num?)?.toInt(),
lastKnownMessageId: $checkedConvert( lastKnownMessageId: (json['lastKnownMessageId'] as num?)?.toInt(),
'lastKnownMessageId', lastCommonReadId: (json['lastCommonReadId'] as num?)?.toInt(),
(v) => (v as num?)?.toInt(), timeout: (json['timeout'] as num?)?.toInt(),
setReadMarker: $enumDecodeNullable(
_$GetChatParamsSwitchEnumMap,
json['setReadMarker'],
), ),
lastCommonReadId: $checkedConvert( includeLastKnown: $enumDecodeNullable(
'lastCommonReadId', _$GetChatParamsSwitchEnumMap,
(v) => (v as num?)?.toInt(), json['includeLastKnown'],
),
timeout: $checkedConvert('timeout', (v) => (v as num?)?.toInt()),
setReadMarker: $checkedConvert(
'setReadMarker',
(v) => $enumDecodeNullable(_$GetChatParamsSwitchEnumMap, v),
),
includeLastKnown: $checkedConvert(
'includeLastKnown',
(v) => $enumDecodeNullable(_$GetChatParamsSwitchEnumMap, v),
), ),
); );
return val;
});
Map<String, dynamic> _$GetChatParamsToJson( Map<String, dynamic> _$GetChatParamsToJson(
GetChatParams instance, GetChatParams instance,
@@ -2,14 +2,12 @@ import 'package:json_annotation/json_annotation.dart';
import '../../../../extensions/date_time.dart'; import '../../../../extensions/date_time.dart';
import '../../../api_response.dart'; import '../../../api_response.dart';
import '../../../json_lenient.dart';
import '../room/get_room_response.dart'; import '../room/get_room_response.dart';
part 'get_chat_response.g.dart'; part 'get_chat_response.g.dart';
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetChatResponse extends ApiResponse { class GetChatResponse extends ApiResponse {
@JsonKey(fromJson: _messagesFromJson)
Set<GetChatResponseObject> data; Set<GetChatResponseObject> data;
GetChatResponse(this.data); GetChatResponse(this.data);
@@ -25,20 +23,15 @@ class GetChatResponse extends ApiResponse {
} }
} }
Set<GetChatResponseObject> _messagesFromJson(Object? raw) =>
decodeSetLenient(raw, GetChatResponseObject.fromJson, what: 'message');
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetChatResponseObject { class GetChatResponseObject {
int id; int id;
String token; String token;
@JsonKey(unknownEnumValue: GetRoomResponseObjectMessageActorType.unknown)
GetRoomResponseObjectMessageActorType actorType; GetRoomResponseObjectMessageActorType actorType;
String actorId; String actorId;
String actorDisplayName; String actorDisplayName;
int timestamp; int timestamp;
String systemMessage; String systemMessage;
@JsonKey(unknownEnumValue: GetRoomResponseObjectMessageType.unknown)
GetRoomResponseObjectMessageType messageType; GetRoomResponseObjectMessageType messageType;
bool isReplyable; bool isReplyable;
String referenceId; String referenceId;
@@ -47,11 +40,6 @@ class GetChatResponseObject {
List<String>? reactionsSelf; List<String>? reactionsSelf;
@JsonKey(fromJson: _fromJson) @JsonKey(fromJson: _fromJson)
Map<String, RichObjectString>? messageParameters; Map<String, RichObjectString>? messageParameters;
/// The quoted message. When the original is no longer available (expired,
/// purged) the server only sends `{id, deleted: true}`; that becomes a
/// deleted-comment placeholder so the quote still renders.
@JsonKey(fromJson: _parentFromJson)
GetChatResponseObject? parent; GetChatResponseObject? parent;
/// Whether the server flagged this message to be rendered as Markdown /// Whether the server flagged this message to be rendered as Markdown
@@ -82,33 +70,10 @@ class GetChatResponseObject {
_$GetChatResponseObjectFromJson(json); _$GetChatResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetChatResponseObjectToJson(this); Map<String, dynamic> toJson() => _$GetChatResponseObjectToJson(this);
/// [timestamp] is in seconds. static GetChatResponseObject getDateDummy(int timestamp) {
DateTime get sentAt => DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
return getTextDummy(elementDate.formatDateRelativeShort());
static GetChatResponseObject getDateDummy(int timestamp) => getTextDummy( }
DateTime.fromMillisecondsSinceEpoch(
timestamp * 1000,
).formatDateRelativeShort(),
);
static GetChatResponseObject deletedPlaceholder(int id) =>
GetChatResponseObject(
id,
'',
GetRoomResponseObjectMessageActorType.user,
'',
'',
0,
'',
GetRoomResponseObjectMessageType.deletedComment,
false,
'',
'Nachricht wurde gelöscht',
null,
null,
null,
null,
);
static GetChatResponseObject getTextDummy(String text) => static GetChatResponseObject getTextDummy(String text) =>
GetChatResponseObject( GetChatResponseObject(
@@ -130,24 +95,14 @@ class GetChatResponseObject {
); );
} }
GetChatResponseObject? _parentFromJson(Object? json) {
if (json is! Map<String, dynamic>) return null;
if (json['deleted'] == true) {
return GetChatResponseObject.deletedPlaceholder(
(json['id'] as num?)?.toInt() ?? 0,
);
}
return GetChatResponseObject.fromJson(json);
}
Map<String, RichObjectString>? _fromJson(dynamic json) { Map<String, RichObjectString>? _fromJson(dynamic json) {
if (json is Map<String, dynamic>) { if (json is Map<String, dynamic>) {
final data = <String, RichObjectString>{}; final data = <String, RichObjectString>{};
for (final entry in json.entries) { for (final element in json.keys) {
final value = entry.value; data.putIfAbsent(
if (value is Map<String, dynamic>) { element,
data[entry.key] = RichObjectString.fromJson(value); () => RichObjectString.fromJson(json[element] as Map<String, dynamic>),
} );
} }
return data; return data;
} }
@@ -157,7 +112,6 @@ Map<String, RichObjectString>? _fromJson(dynamic json) {
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class RichObjectString { class RichObjectString {
@JsonKey(unknownEnumValue: RichObjectStringObjectType.unknown)
RichObjectStringObjectType type; RichObjectStringObjectType type;
String id; String id;
String name; String name;
@@ -165,17 +119,7 @@ class RichObjectString {
String? path; String? path;
String? link; String? link;
/// Only set for `file` objects. RichObjectString(this.type, this.id, this.name, this.path, this.link);
String? mimetype;
RichObjectString(
this.type,
this.id,
this.name,
this.path,
this.link, [
this.mimetype,
]);
factory RichObjectString.fromJson(Map<String, dynamic> json) => factory RichObjectString.fromJson(Map<String, dynamic> json) =>
_$RichObjectStringFromJson(json); _$RichObjectStringFromJson(json);
@@ -199,14 +143,4 @@ enum RichObjectStringObjectType {
geoLocation, geoLocation,
@JsonValue('call') @JsonValue('call')
call, call,
@JsonValue('user-group')
userGroup,
@JsonValue('circle')
circle,
@JsonValue('email')
email,
/// Anything else (deck cards, contacts, calendar events, ...); the
/// placeholder still resolves to the object's name.
unknown,
} }
@@ -7,18 +7,16 @@ part of 'get_chat_response.dart';
// ************************************************************************** // **************************************************************************
GetChatResponse _$GetChatResponseFromJson(Map<String, dynamic> json) => GetChatResponse _$GetChatResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('GetChatResponse', json, ($checkedConvert) { GetChatResponse(
final val = GetChatResponse( (json['data'] as List<dynamic>)
$checkedConvert('data', (v) => _messagesFromJson(v)), .map(
); (e) => GetChatResponseObject.fromJson(e as Map<String, dynamic>),
$checkedConvert( )
'headers', .toSet(),
(v) => val.headers = (v as Map<String, dynamic>?)?.map( )
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String), (k, e) => MapEntry(k, e as String),
),
); );
return val;
});
Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) => Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -28,49 +26,31 @@ Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) =>
GetChatResponseObject _$GetChatResponseObjectFromJson( GetChatResponseObject _$GetChatResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetChatResponseObject', json, ($checkedConvert) { ) => GetChatResponseObject(
final val = GetChatResponseObject( (json['id'] as num).toInt(),
$checkedConvert('id', (v) => (v as num).toInt()), json['token'] as String,
$checkedConvert('token', (v) => v as String), $enumDecode(
$checkedConvert(
'actorType',
(v) => $enumDecode(
_$GetRoomResponseObjectMessageActorTypeEnumMap, _$GetRoomResponseObjectMessageActorTypeEnumMap,
v, json['actorType'],
unknownValue: GetRoomResponseObjectMessageActorType.unknown,
), ),
), json['actorId'] as String,
$checkedConvert('actorId', (v) => v as String), json['actorDisplayName'] as String,
$checkedConvert('actorDisplayName', (v) => v as String), (json['timestamp'] as num).toInt(),
$checkedConvert('timestamp', (v) => (v as num).toInt()), json['systemMessage'] as String,
$checkedConvert('systemMessage', (v) => v as String), $enumDecode(_$GetRoomResponseObjectMessageTypeEnumMap, json['messageType']),
$checkedConvert( json['isReplyable'] as bool,
'messageType', json['referenceId'] as String,
(v) => $enumDecode( json['message'] as String,
_$GetRoomResponseObjectMessageTypeEnumMap, _fromJson(json['messageParameters']),
v, (json['reactions'] as Map<String, dynamic>?)?.map(
unknownValue: GetRoomResponseObjectMessageType.unknown,
),
),
$checkedConvert('isReplyable', (v) => v as bool),
$checkedConvert('referenceId', (v) => v as String),
$checkedConvert('message', (v) => v as String),
$checkedConvert('messageParameters', (v) => _fromJson(v)),
$checkedConvert(
'reactions',
(v) => (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()), (k, e) => MapEntry(k, (e as num).toInt()),
), ),
), (json['reactionsSelf'] as List<dynamic>?)?.map((e) => e as String).toList(),
$checkedConvert( json['parent'] == null
'reactionsSelf', ? null
(v) => (v as List<dynamic>?)?.map((e) => e as String).toList(), : GetChatResponseObject.fromJson(json['parent'] as Map<String, dynamic>),
), json['markdown'] as bool? ?? false,
$checkedConvert('parent', (v) => _parentFromJson(v)),
$checkedConvert('markdown', (v) => v as bool? ?? false),
); );
return val;
});
Map<String, dynamic> _$GetChatResponseObjectToJson( Map<String, dynamic> _$GetChatResponseObjectToJson(
GetChatResponseObject instance, GetChatResponseObject instance,
@@ -103,12 +83,6 @@ const _$GetRoomResponseObjectMessageActorTypeEnumMap = {
GetRoomResponseObjectMessageActorType.guest: 'guests', GetRoomResponseObjectMessageActorType.guest: 'guests',
GetRoomResponseObjectMessageActorType.bot: 'bots', GetRoomResponseObjectMessageActorType.bot: 'bots',
GetRoomResponseObjectMessageActorType.bridge: 'bridged', GetRoomResponseObjectMessageActorType.bridge: 'bridged',
GetRoomResponseObjectMessageActorType.group: 'groups',
GetRoomResponseObjectMessageActorType.email: 'emails',
GetRoomResponseObjectMessageActorType.circle: 'circles',
GetRoomResponseObjectMessageActorType.federatedUser: 'federated_users',
GetRoomResponseObjectMessageActorType.phone: 'phones',
GetRoomResponseObjectMessageActorType.unknown: 'unknown',
}; };
const _$GetRoomResponseObjectMessageTypeEnumMap = { const _$GetRoomResponseObjectMessageTypeEnumMap = {
@@ -117,28 +91,16 @@ const _$GetRoomResponseObjectMessageTypeEnumMap = {
GetRoomResponseObjectMessageType.deletedComment: 'comment_deleted', GetRoomResponseObjectMessageType.deletedComment: 'comment_deleted',
GetRoomResponseObjectMessageType.system: 'system', GetRoomResponseObjectMessageType.system: 'system',
GetRoomResponseObjectMessageType.command: 'command', GetRoomResponseObjectMessageType.command: 'command',
GetRoomResponseObjectMessageType.unknown: 'unknown',
}; };
RichObjectString _$RichObjectStringFromJson(Map<String, dynamic> json) => RichObjectString _$RichObjectStringFromJson(Map<String, dynamic> json) =>
$checkedCreate('RichObjectString', json, ($checkedConvert) { RichObjectString(
final val = RichObjectString( $enumDecode(_$RichObjectStringObjectTypeEnumMap, json['type']),
$checkedConvert( json['id'] as String,
'type', json['name'] as String,
(v) => $enumDecode( json['path'] as String?,
_$RichObjectStringObjectTypeEnumMap, json['link'] as String?,
v,
unknownValue: RichObjectStringObjectType.unknown,
),
),
$checkedConvert('id', (v) => v as String),
$checkedConvert('name', (v) => v as String),
$checkedConvert('path', (v) => v as String?),
$checkedConvert('link', (v) => v as String?),
$checkedConvert('mimetype', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) => Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -147,7 +109,6 @@ Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) =>
'name': instance.name, 'name': instance.name,
'path': instance.path, 'path': instance.path,
'link': instance.link, 'link': instance.link,
'mimetype': instance.mimetype,
}; };
const _$RichObjectStringObjectTypeEnumMap = { const _$RichObjectStringObjectTypeEnumMap = {
@@ -159,8 +120,4 @@ const _$RichObjectStringObjectTypeEnumMap = {
RichObjectStringObjectType.talkPoll: 'talk-poll', RichObjectStringObjectType.talkPoll: 'talk-poll',
RichObjectStringObjectType.geoLocation: 'geo-location', RichObjectStringObjectType.geoLocation: 'geo-location',
RichObjectStringObjectType.call: 'call', RichObjectStringObjectType.call: 'call',
RichObjectStringObjectType.userGroup: 'user-group',
RichObjectStringObjectType.circle: 'circle',
RichObjectStringObjectType.email: 'email',
RichObjectStringObjectType.unknown: 'unknown',
}; };
@@ -1,10 +1,15 @@
import 'fetch_chat_page.dart'; import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
import 'get_chat_response.dart'; import 'get_chat_response.dart';
/// Long-poll variant of GetChat (`lookIntoFuture=1`); HTTP 304 is the normal /// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi]
/// "no new messages" outcome (see [fetchChatPage]). `setReadMarker=on` lets /// because that layer treats non-2xx as errors, and we need 304 to be a
/// the server move the read cursor whenever the call returns messages. /// normal "no new messages" outcome. `setReadMarker=on` lets the server
/// move the read cursor whenever the call returns messages.
class LongPollChat { class LongPollChat {
final String chatToken; final String chatToken;
final int lastKnownMessageId; final int lastKnownMessageId;
@@ -17,17 +22,37 @@ class LongPollChat {
}); });
/// Returns the response, or `null` on HTTP 304 (server timeout, nothing new). /// Returns the response, or `null` on HTTP 304 (server timeout, nothing new).
Future<GetChatResponse?> run() => fetchChatPage( Future<GetChatResponse?> run() async {
label: 'LongPollChat', final params = GetChatParams(
chatToken: chatToken,
params: GetChatParams(
lookIntoFuture: GetChatParamsSwitch.on, lookIntoFuture: GetChatParamsSwitch.on,
timeout: timeoutSeconds, timeout: timeoutSeconds,
lastKnownMessageId: lastKnownMessageId, lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off, includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on, setReadMarker: GetChatParamsSwitch.on,
limit: 100, limit: 100,
), );
timeout: Duration(seconds: timeoutSeconds + 15), final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'LongPollChat $uri',
() => http
.get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15)),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'LongPollChat $uri: HTTP $status',
); );
} }
}
@@ -7,18 +7,14 @@ part of 'create_poll_params.dart';
// ************************************************************************** // **************************************************************************
CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) => CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) =>
$checkedCreate('CreatePollParams', json, ($checkedConvert) { CreatePollParams(
final val = CreatePollParams( question: json['question'] as String,
question: $checkedConvert('question', (v) => v as String), options: (json['options'] as List<dynamic>)
options: $checkedConvert( .map((e) => e as String)
'options', .toList(),
(v) => (v as List<dynamic>).map((e) => e as String).toList(), resultMode: (json['resultMode'] as num).toInt(),
), maxVotes: (json['maxVotes'] as num).toInt(),
resultMode: $checkedConvert('resultMode', (v) => (v as num).toInt()),
maxVotes: $checkedConvert('maxVotes', (v) => (v as num).toInt()),
); );
return val;
});
Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) => Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -7,17 +7,14 @@ part of 'create_room_params.dart';
// ************************************************************************** // **************************************************************************
CreateRoomParams _$CreateRoomParamsFromJson(Map<String, dynamic> json) => CreateRoomParams _$CreateRoomParamsFromJson(Map<String, dynamic> json) =>
$checkedCreate('CreateRoomParams', json, ($checkedConvert) { CreateRoomParams(
final val = CreateRoomParams( roomType: (json['roomType'] as num).toInt(),
roomType: $checkedConvert('roomType', (v) => (v as num).toInt()), invite: json['invite'] as String,
invite: $checkedConvert('invite', (v) => v as String), source: json['source'] as String?,
source: $checkedConvert('source', (v) => v as String?), roomName: json['roomName'] as String?,
roomName: $checkedConvert('roomName', (v) => v as String?), objectType: json['objectType'] as String?,
objectType: $checkedConvert('objectType', (v) => v as String?), objectId: json['objectId'] as String?,
objectId: $checkedConvert('objectId', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$CreateRoomParamsToJson(CreateRoomParams instance) => Map<String, dynamic> _$CreateRoomParamsToJson(CreateRoomParams instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -7,21 +7,12 @@ part of 'create_room_response.dart';
// ************************************************************************** // **************************************************************************
CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) => CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) =>
$checkedCreate('CreateRoomResponse', json, ($checkedConvert) { CreateRoomResponse(
final val = CreateRoomResponse( CreateRoomResponseData.fromJson(json['data'] as Map<String, dynamic>),
$checkedConvert( )
'data', ..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(v) => CreateRoomResponseData.fromJson(v as Map<String, dynamic>),
),
);
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String), (k, e) => MapEntry(k, e as String),
),
); );
return val;
});
Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) => Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -31,12 +22,7 @@ Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
CreateRoomResponseData _$CreateRoomResponseDataFromJson( CreateRoomResponseData _$CreateRoomResponseDataFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('CreateRoomResponseData', json, ($checkedConvert) { ) => CreateRoomResponseData(json['token'] as String);
final val = CreateRoomResponseData(
$checkedConvert('token', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$CreateRoomResponseDataToJson( Map<String, dynamic> _$CreateRoomResponseDataToJson(
CreateRoomResponseData instance, CreateRoomResponseData instance,
@@ -8,12 +8,7 @@ part of 'delete_react_message_params.dart';
DeleteReactMessageParams _$DeleteReactMessageParamsFromJson( DeleteReactMessageParams _$DeleteReactMessageParamsFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('DeleteReactMessageParams', json, ($checkedConvert) { ) => DeleteReactMessageParams(json['reaction'] as String);
final val = DeleteReactMessageParams(
$checkedConvert('reaction', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$DeleteReactMessageParamsToJson( Map<String, dynamic> _$DeleteReactMessageParamsToJson(
DeleteReactMessageParams instance, DeleteReactMessageParams instance,
@@ -1,3 +1,5 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_participants_response.dart'; import 'get_participants_response.dart';
@@ -9,4 +11,11 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
@override @override
GetParticipantsResponse assemble(String raw) => GetParticipantsResponse assemble(String raw) =>
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw)); GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,13 +1,11 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart'; import '../../../api_response.dart';
import '../../../json_lenient.dart';
part 'get_participants_response.g.dart'; part 'get_participants_response.g.dart';
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetParticipantsResponse extends ApiResponse { class GetParticipantsResponse extends ApiResponse {
@JsonKey(fromJson: _participantsFromJson)
Set<GetParticipantsResponseObject> data; Set<GetParticipantsResponseObject> data;
GetParticipantsResponse(this.data); GetParticipantsResponse(this.data);
@@ -17,25 +15,15 @@ class GetParticipantsResponse extends ApiResponse {
Map<String, dynamic> toJson() => _$GetParticipantsResponseToJson(this); Map<String, dynamic> toJson() => _$GetParticipantsResponseToJson(this);
} }
Set<GetParticipantsResponseObject> _participantsFromJson(Object? raw) =>
decodeSetLenient(
raw,
GetParticipantsResponseObject.fromJson,
what: 'participant',
);
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GetParticipantsResponseObject { class GetParticipantsResponseObject {
int attendeeId; int attendeeId;
String actorType; String actorType;
String actorId; String actorId;
String displayName; String displayName;
@JsonKey(unknownEnumValue: GetParticipantsResponseObjectParticipantType.user)
GetParticipantsResponseObjectParticipantType participantType; GetParticipantsResponseObjectParticipantType participantType;
int lastPing; int lastPing;
GetParticipantsResponseObjectParticipantsInCallFlags inCall;
/// Bitmask: 1 = in call, 2 = with audio, 4 = with video, 8 = via phone.
int inCall;
int permissions; int permissions;
int attendeePermissions; int attendeePermissions;
String? sessionId; String? sessionId;
@@ -66,11 +54,6 @@ class GetParticipantsResponseObject {
factory GetParticipantsResponseObject.fromJson(Map<String, dynamic> json) => factory GetParticipantsResponseObject.fromJson(Map<String, dynamic> json) =>
_$GetParticipantsResponseObjectFromJson(json); _$GetParticipantsResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetParticipantsResponseObjectToJson(this); Map<String, dynamic> toJson() => _$GetParticipantsResponseObjectToJson(this);
bool get isInCall => inCall & 1 != 0;
bool get inCallWithAudio => inCall & 2 != 0;
bool get inCallWithVideo => inCall & 4 != 0;
bool get inCallViaPhone => inCall & 8 != 0;
} }
enum GetParticipantsResponseObjectParticipantType { enum GetParticipantsResponseObjectParticipantType {
@@ -90,3 +73,16 @@ enum GetParticipantsResponseObjectParticipantType {
const GetParticipantsResponseObjectParticipantType(this.prettyName); const GetParticipantsResponseObjectParticipantType(this.prettyName);
final String prettyName; final String prettyName;
} }
enum GetParticipantsResponseObjectParticipantsInCallFlags {
@JsonValue(0)
disconnected,
@JsonValue(1)
inCall,
@JsonValue(2)
providesAudio,
@JsonValue(3)
providesVideo,
@JsonValue(4)
usesSipDialIn,
}
@@ -8,18 +8,19 @@ part of 'get_participants_response.dart';
GetParticipantsResponse _$GetParticipantsResponseFromJson( GetParticipantsResponse _$GetParticipantsResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetParticipantsResponse', json, ($checkedConvert) { ) =>
final val = GetParticipantsResponse( GetParticipantsResponse(
$checkedConvert('data', (v) => _participantsFromJson(v)), (json['data'] as List<dynamic>)
); .map(
$checkedConvert( (e) => GetParticipantsResponseObject.fromJson(
'headers', e as Map<String, dynamic>,
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
), ),
)
.toSet(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
); );
return val;
});
Map<String, dynamic> _$GetParticipantsResponseToJson( Map<String, dynamic> _$GetParticipantsResponseToJson(
GetParticipantsResponse instance, GetParticipantsResponse instance,
@@ -30,36 +31,29 @@ Map<String, dynamic> _$GetParticipantsResponseToJson(
GetParticipantsResponseObject _$GetParticipantsResponseObjectFromJson( GetParticipantsResponseObject _$GetParticipantsResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetParticipantsResponseObject', json, ($checkedConvert) { ) => GetParticipantsResponseObject(
final val = GetParticipantsResponseObject( (json['attendeeId'] as num).toInt(),
$checkedConvert('attendeeId', (v) => (v as num).toInt()), json['actorType'] as String,
$checkedConvert('actorType', (v) => v as String), json['actorId'] as String,
$checkedConvert('actorId', (v) => v as String), json['displayName'] as String,
$checkedConvert('displayName', (v) => v as String), $enumDecode(
$checkedConvert(
'participantType',
(v) => $enumDecode(
_$GetParticipantsResponseObjectParticipantTypeEnumMap, _$GetParticipantsResponseObjectParticipantTypeEnumMap,
v, json['participantType'],
unknownValue: GetParticipantsResponseObjectParticipantType.user,
), ),
(json['lastPing'] as num).toInt(),
$enumDecode(
_$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap,
json['inCall'],
), ),
$checkedConvert('lastPing', (v) => (v as num).toInt()), (json['permissions'] as num).toInt(),
$checkedConvert('inCall', (v) => (v as num).toInt()), (json['attendeePermissions'] as num).toInt(),
$checkedConvert('permissions', (v) => (v as num).toInt()), json['sessionId'] as String?,
$checkedConvert('attendeePermissions', (v) => (v as num).toInt()), (json['sessionIds'] as List<dynamic>).map((e) => e as String).toList(),
$checkedConvert('sessionId', (v) => v as String?), json['status'] as String?,
$checkedConvert( json['statusIcon'] as String?,
'sessionIds', json['statusMessage'] as String?,
(v) => (v as List<dynamic>).map((e) => e as String).toList(), json['roomToken'] as String?,
),
$checkedConvert('status', (v) => v as String?),
$checkedConvert('statusIcon', (v) => v as String?),
$checkedConvert('statusMessage', (v) => v as String?),
$checkedConvert('roomToken', (v) => v as String?),
); );
return val;
});
Map<String, dynamic> _$GetParticipantsResponseObjectToJson( Map<String, dynamic> _$GetParticipantsResponseObjectToJson(
GetParticipantsResponseObject instance, GetParticipantsResponseObject instance,
@@ -72,7 +66,9 @@ Map<String, dynamic> _$GetParticipantsResponseObjectToJson(
_$GetParticipantsResponseObjectParticipantTypeEnumMap[instance _$GetParticipantsResponseObjectParticipantTypeEnumMap[instance
.participantType]!, .participantType]!,
'lastPing': instance.lastPing, 'lastPing': instance.lastPing,
'inCall': instance.inCall, 'inCall':
_$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap[instance
.inCall]!,
'permissions': instance.permissions, 'permissions': instance.permissions,
'attendeePermissions': instance.attendeePermissions, 'attendeePermissions': instance.attendeePermissions,
'sessionId': instance.sessionId, 'sessionId': instance.sessionId,
@@ -91,3 +87,11 @@ const _$GetParticipantsResponseObjectParticipantTypeEnumMap = {
GetParticipantsResponseObjectParticipantType.userFollowingPublicLink: 5, GetParticipantsResponseObjectParticipantType.userFollowingPublicLink: 5,
GetParticipantsResponseObjectParticipantType.guestWithModeratorPermissions: 6, GetParticipantsResponseObjectParticipantType.guestWithModeratorPermissions: 6,
}; };
const _$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap = {
GetParticipantsResponseObjectParticipantsInCallFlags.disconnected: 0,
GetParticipantsResponseObjectParticipantsInCallFlags.inCall: 1,
GetParticipantsResponseObjectParticipantsInCallFlags.providesAudio: 2,
GetParticipantsResponseObjectParticipantsInCallFlags.providesVideo: 3,
GetParticipantsResponseObjectParticipantsInCallFlags.usesSipDialIn: 4,
};
@@ -1,3 +1,5 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_poll_state_response.dart'; import 'get_poll_state_response.dart';
@@ -11,4 +13,11 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
@override @override
GetPollStateResponse assemble(String raw) => GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw)); GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -8,21 +8,15 @@ part of 'get_poll_state_response.dart';
GetPollStateResponse _$GetPollStateResponseFromJson( GetPollStateResponse _$GetPollStateResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetPollStateResponse', json, ($checkedConvert) { ) =>
final val = GetPollStateResponse( GetPollStateResponse(
$checkedConvert( GetPollStateResponseObject.fromJson(
'data', json['data'] as Map<String, dynamic>,
(v) => GetPollStateResponseObject.fromJson(v as Map<String, dynamic>),
), ),
); )
$checkedConvert( ..headers = (json['headers'] as Map<String, dynamic>?)?.map(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String), (k, e) => MapEntry(k, e as String),
),
); );
return val;
});
Map<String, dynamic> _$GetPollStateResponseToJson( Map<String, dynamic> _$GetPollStateResponseToJson(
GetPollStateResponse instance, GetPollStateResponse instance,
@@ -33,30 +27,21 @@ Map<String, dynamic> _$GetPollStateResponseToJson(
GetPollStateResponseObject _$GetPollStateResponseObjectFromJson( GetPollStateResponseObject _$GetPollStateResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => $checkedCreate('GetPollStateResponseObject', json, ($checkedConvert) { ) => GetPollStateResponseObject(
final val = GetPollStateResponseObject( (json['id'] as num).toInt(),
$checkedConvert('id', (v) => (v as num).toInt()), json['question'] as String,
$checkedConvert('question', (v) => v as String), (json['options'] as List<dynamic>).map((e) => e as String).toList(),
$checkedConvert( json['votes'],
'options', json['actorType'] as String,
(v) => (v as List<dynamic>).map((e) => e as String).toList(), json['actorId'] as String,
), json['actorDisplayName'] as String,
$checkedConvert('votes', (v) => v), (json['status'] as num).toInt(),
$checkedConvert('actorType', (v) => v as String), (json['resultMode'] as num).toInt(),
$checkedConvert('actorId', (v) => v as String), (json['maxVotes'] as num).toInt(),
$checkedConvert('actorDisplayName', (v) => v as String), (json['votedSelf'] as List<dynamic>).map((e) => (e as num).toInt()).toList(),
$checkedConvert('status', (v) => (v as num).toInt()), (json['numVoters'] as num?)?.toInt(),
$checkedConvert('resultMode', (v) => (v as num).toInt()), json['details'] as List<dynamic>?,
$checkedConvert('maxVotes', (v) => (v as num).toInt()),
$checkedConvert(
'votedSelf',
(v) => (v as List<dynamic>).map((e) => (e as num).toInt()).toList(),
),
$checkedConvert('numVoters', (v) => (v as num?)?.toInt()),
$checkedConvert('details', (v) => v as List<dynamic>?),
); );
return val;
});
Map<String, dynamic> _$GetPollStateResponseObjectToJson( Map<String, dynamic> _$GetPollStateResponseObjectToJson(
GetPollStateResponseObject instance, GetPollStateResponseObject instance,
Loaded 100 of 564 files, more files were not shown because too many files have changed in this diff. Show more