25 Commits
Author SHA1 Message Date
MineTec b9cb6a8af1 chat search now lists local and server-side hits like the file search, and jumping to old messages pages in history with a progress banner and keeps the hit marked 2026-10-28 22:40:21 +01:00
MineTec fcfd21bb4d bumped version to 1.7.0+68 2026-10-28 22:16:12 +01:00
MineTec aa176c90e3 added a swipeable media gallery for chat and folder images and videos with server previews, on-demand originals and zoom, and marked document previews in chat so they no longer pass for photos 2026-10-28 22:15:57 +01:00
MineTec 952f916231 bumped version to 1.6.1+67 2026-10-19 09:51:20 +02:00
MineTec 9ea0eae53c Merge remote-tracking branch 'origin/develop' into develop 2026-10-19 09:49:31 +02:00
MineTec 4a4e101532 share extension now accepts raw image data and falls back to a file copy, so sharing from the markup editor no longer closes immediately 2026-10-19 09:49:23 +02:00
MineTec 23e4510bf8 fixed client-reported crashes in the pdf viewer, image picker and prosemirror images, mapped webdav errors to user messages and hardened keychain, cache and push stores against io failures 2026-10-12 17:00:14 +02:00
MineTecandClaude Opus 5.5 c5592f117b added automated App Store screenshots via iOS simulators and fastlane deliver
tool/screenshots_ios.sh runs the existing demo-login integration test on
iPhone 6.5" (iPhone 14 Plus) and iPad 13" simulators, stores the canonical PNGs
under materials/screenshots and mirrors alpha-free JPEGs to
ios/fastlane/screenshots for `fastlane upload_screenshots`.

Fixed the demo router answering timetable/custom-events with a week, which
crashed the inline iOS widget refresh after a demo login. The screenshot test
now skips the one-time Talk notification dialog and waits longer for the app
shell on cold simulators.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:58:29 +02:00
MineTec ada4d6a77c Merge remote-tracking branch 'origin/develop' into develop 2026-09-29 16:39:55 +02:00
MineTecandClaude Fable 5.1 cd88cc75fc required firebase_messaging 16.6 so AuthorizationStatus.deniedPermanently resolves
The push code references the value since the platform upgrade in September,
but the old constraint still allowed firebase_messaging_platform_interface
4.9.0, which lacks it; a stale local lockfile then failed analyze and eleven
test files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrepDTL4qDe35TuS29sLW3
2026-09-29 16:35:41 +02:00
MineTecandClaude Fable 5.1 279b973e89 hardened API decoding against server fields the models did not expect
The Talk room list crashed for a new account because the server omits
lastMessage for rooms without a visible message and the model required it.
Beyond that single field, a review of the Talk models against the OpenAPI
spec of the installed version (23.0.8) found the same crash class in several
places: closed enums over open server values (inCall bitmask, rich-object
types, actor and message types), a deleted parent sent as {id, deleted},
and the autocomplete status arriving as an object.

- json_serializable `checked: true`: decode errors now name class and field
- lists (rooms, messages, participants, autocomplete) decode per entry;
  a malformed entry is skipped and reported via ClientErrorReporter
- unknownEnumValue fallbacks for all response enums of Talk and breakers
- lastMessage nullable; inCall as bitmask; deleted parent placeholder;
  autocomplete status object/string; fixed shareWithDisplayNameUnique key
- GetRoomParams no longer sends null query parameters

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrepDTL4qDe35TuS29sLW3
2026-09-29 16:35:41 +02:00
MineTec b90cda62da iOS build 2026-09-29 15:53:18 +02:00
MineTec a79b376bb3 bumped version to 1.6.0+66 2026-09-27 21:41:49 +02:00
MineTec eddd294a34 blocked all nextcloud requests in demo mode to stop fake-credential logins tripping brute-force protection 2026-09-27 21:40:55 +02:00
MineTec c455bb1cae consolidated downloads dir, nextcloud origin, upload route and chat timestamps; added webdav path tests 2026-09-27 21:19:50 +02:00
MineTec aca86c506a fixed countdown wrap, css rgba colors, stale create-link gate and wired up reply drafts in talk 2026-09-27 21:13:44 +02:00
MineTec 2664378b17 fixed overwrite on internal copy, DST drift in widget anchors, inline errors without controller and removed dead room lookup 2026-09-27 17:37:31 +02:00
MineTec ee1240f71a simplified code across the app and consolidated duplicated helpers 2026-09-27 17:29:54 +02:00
MineTec 3e856d0b1c updated project notes to the current backends 2026-09-27 14:57:49 +02:00
MineTec 8eddc8f771 fixed widget background refresh loading custom events from the legacy backend 2026-09-27 14:54:10 +02:00
MineTec 27563a6dc1 added support for multiple accounts with an account switcher in settings 2026-09-27 14:54:10 +02:00
MineTec 5e65c4671a replaced account data singleton with a session model 2026-09-27 14:49:38 +02:00
MineTec 79b33ab248 reworked login screen texts and extracted shared login form parts 2026-09-27 14:40:04 +02:00
MineTec 1fd6264d10 fixed notification taps being ignored when they cold-started the app 2026-09-27 14:38:48 +02:00
MineTec 2ca9feb49a reworked home screen widgets to mirror the in-app timetable, hidden below android 12 2026-09-27 14:07:52 +02:00
439 changed files with 13590 additions and 7085 deletions

No files matched your search

+6
View File
@@ -362,3 +362,9 @@ 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
+10 -6
View File
@@ -1,6 +1,6 @@
# MarianumMobile Client # MarianumMobile Client
Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push). Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files.
## Stack ## Stack
@@ -16,7 +16,8 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil
``` ```
lib/ lib/
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/) ├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/)
├── session/ Session-Modell, SessionManager, SessionLifecycle, Konten-Index
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) ├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine ├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
├── view/ Screens ├── view/ Screens
@@ -51,6 +52,10 @@ 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).
**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.
## Build / Run ## Build / Run
```bash ```bash
@@ -65,13 +70,12 @@ flutter test # Tests (siehe test
| Backend | Pfad | Zweck | | Backend | Pfad | Zweck |
|---------------------------|-----------------------|----------------------------------------| |---------------------------|-----------------------|----------------------------------------|
| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer | | MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Capabilities, Push |
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung | | Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten | | MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
| Holiday-Calendar | `lib/api/holidays/` | Ferien |
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`). `nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
## Tests ## Tests
`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu. `test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
+2
View File
@@ -81,6 +81,7 @@
the home_widget Flutter plugin resolves them as <app-package>.<name>. --> the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
<receiver <receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget" android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
android:enabled="@bool/widget_supported"
android:label="@string/widget_day_label" android:label="@string/widget_day_label"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@@ -93,6 +94,7 @@
<receiver <receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget" android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
android:enabled="@bool/widget_supported"
android:label="@string/widget_week_label" android:label="@string/widget_week_label"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@@ -11,7 +11,7 @@ import java.util.TimeZone
// Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names // Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names
// must stay in sync. // must stay in sync.
enum class WidgetLessonStatus { enum class WidgetLessonStatus {
REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT; REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT, DUTY;
companion object { companion object {
fun fromWire(raw: String?): WidgetLessonStatus = when (raw) { fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
@@ -22,6 +22,7 @@ enum class WidgetLessonStatus {
"irregular" -> IRREGULAR "irregular" -> IRREGULAR
"teacherChanged" -> TEACHER_CHANGED "teacherChanged" -> TEACHER_CHANGED
"event" -> EVENT "event" -> EVENT
"duty" -> DUTY
else -> REGULAR else -> REGULAR
} }
} }
@@ -39,6 +40,9 @@ data class WidgetLesson(
val originalTeacher: String?, val originalTeacher: String?,
val status: WidgetLessonStatus, val status: WidgetLessonStatus,
val customColor: String?, val customColor: String?,
// Tile colour resolved in Dart (subject colours, palettes); null only for
// payloads written before the field existed.
val color: Int?,
val siblingCount: Int, val siblingCount: Int,
) )
@@ -50,6 +54,12 @@ data class WidgetPeriod(
val virtualEndMinutes: Int, val virtualEndMinutes: Int,
) )
data class WidgetDayInfo(
val date: Date,
val isHoliday: Boolean,
val holidayName: String?,
)
data class WidgetTimetableData( data class WidgetTimetableData(
val fetchedAt: Date, val fetchedAt: Date,
val anchorDate: Date, val anchorDate: Date,
@@ -57,6 +67,8 @@ data class WidgetTimetableData(
val periods: List<WidgetPeriod>, val periods: List<WidgetPeriod>,
val isHoliday: Boolean, val isHoliday: Boolean,
val holidayName: String?, val holidayName: String?,
// Week payload only; empty for the day payload.
val days: List<WidgetDayInfo>,
) )
object WidgetDataParser { object WidgetDataParser {
@@ -81,6 +93,11 @@ object WidgetDataParser {
} }
} }
private fun parseColor(raw: String?): Int? {
if (raw == null || !Regex("#[0-9A-Fa-f]{6}").matches(raw)) return null
return 0xFF000000.toInt() or raw.substring(1).toInt(16)
}
fun parse(json: String?): WidgetTimetableData? { fun parse(json: String?): WidgetTimetableData? {
if (json.isNullOrEmpty()) return null if (json.isNullOrEmpty()) return null
return try { return try {
@@ -102,6 +119,7 @@ object WidgetDataParser {
originalTeacher = obj.stringOrNull("originalTeacher"), originalTeacher = obj.stringOrNull("originalTeacher"),
status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")), status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")),
customColor = obj.stringOrNull("customColor"), customColor = obj.stringOrNull("customColor"),
color = parseColor(obj.stringOrNull("color")),
siblingCount = obj.optInt("siblingCount", 0), siblingCount = obj.optInt("siblingCount", 0),
) )
} }
@@ -120,6 +138,18 @@ object WidgetDataParser {
) )
} }
} }
val daysArray = root.optJSONArray("days")
val days = mutableListOf<WidgetDayInfo>()
if (daysArray != null) {
for (i in 0 until daysArray.length()) {
val obj = daysArray.optJSONObject(i) ?: continue
days += WidgetDayInfo(
date = parseDate(obj.stringOrNull("date")) ?: continue,
isHoliday = obj.optBoolean("isHoliday", false),
holidayName = obj.stringOrNull("holidayName"),
)
}
}
WidgetTimetableData( WidgetTimetableData(
fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(), fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(),
anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(), anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(),
@@ -127,6 +157,7 @@ object WidgetDataParser {
periods = periods, periods = periods,
isHoliday = root.optBoolean("isHoliday", false), isHoliday = root.optBoolean("isHoliday", false),
holidayName = root.stringOrNull("holidayName"), holidayName = root.stringOrNull("holidayName"),
days = days,
) )
} catch (_: JSONException) { } catch (_: JSONException) {
null null
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Icons "coffee_outlined", matches the in-app period ruler. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18.52 3H6C4.92 3 3.98 3.89 3.98 5.02V10.69C3.98 14.53 6.94 17.91 10.78 18C14.72 18.14 18 14.95 18 11.02V9.98H18.52C20.44 9.98 21.98 8.44 21.98 6.52C21.98 4.55 20.44 3 18.52 3ZM15.98 5.02V8.02H6V5.02H15.98ZM15.98 9.98V11.02C15.98 13.78 13.78 15.98 11.02 15.98C8.25 15.98 6 13.78 6 11.02V9.98ZM18.52 8.02H18V5.02H18.52C19.31 5.02 20.02 5.67 20.02 6.52C20.02 7.31 19.31 8.02 18.52 8.02ZM3.98 18.98H20.02V21H3.98V18.98Z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Icons "event_outlined", matches the in-app holiday region. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18.98 3.98H18V2.02H15.98V3.98H8.02V2.02H6V3.98H5.02C3.89 3.98 3 4.92 3 6V20.02C3 21.09 3.89 21.98 5.02 21.98H18.98C20.11 21.98 21 21.09 21 20.02V6C21 4.92 20.11 3.98 18.98 3.98ZM18.98 20.02H5.02V9.98H18.98V20.02ZM18.98 8.02H5.02V6H18.98V8.02ZM12 12.98H17.02V18H12V12.98Z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Icons "restaurant", matches the in-app break region. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11.02 9H9V2.02H6.98V9H5.02V2.02H3V9C3 11.11 4.64 12.84 6.75 12.98V21.98H9.23V12.98C11.34 12.84 12.98 11.11 12.98 9V2.02H11.02V9ZM15.98 6V14.02H18.52V21.98H21V2.02C18.23 2.02 15.98 4.22 15.98 6Z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Tinted at runtime with the tile colour resolved in Dart. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="7dp" />
<solid android:color="@android:color/white" />
</shape>
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_cancelled" />
<stroke android:width="1.5dp" android:color="#C8FF0000" />
</shape>
</item>
<item
android:left="3dp"
android:top="3dp"
android:right="3dp"
android:bottom="3dp"
android:drawable="@drawable/widget_lesson_cancelled_x" />
</layer-list>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF2196F3" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF4CAF50" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FFEF6C00" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_irregular" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_ongoing" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_past" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_regular" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_teacher_changed" />
</shape>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<corners android:radius="7dp" />
<solid android:color="@android:color/transparent" />
<stroke android:width="2dp" android:color="#C8F44336" />
</shape>
</item>
<item
android:left="2dp"
android:top="2dp"
android:right="2dp"
android:bottom="2dp"
android:drawable="@drawable/widget_lesson_cancelled_x" />
</layer-list>
@@ -5,7 +5,7 @@
android:viewportWidth="100" android:viewportWidth="100"
android:viewportHeight="100"> android:viewportHeight="100">
<path <path
android:strokeColor="#C8FF0000" android:strokeColor="#C8F44336"
android:strokeWidth="5" android:strokeWidth="2.5"
android:pathData="M0,0 L100,100 M100,0 L0,100" /> android:pathData="M0,0 L100,100 M100,0 L0,100" />
</vector> </vector>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"> android:shape="oval">
<corners android:radius="6dp" /> <solid android:color="@android:color/white" />
<solid android:color="#FF993333" />
</shape> </shape>
@@ -3,6 +3,14 @@
android:id="@+id/widget_break_block_root" android:id="@+id/widget_break_block_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="14dp" android:layout_height="14dp"
android:layout_marginTop="0dp" android:layout_marginTop="0dp">
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp" /> <ImageView
android:id="@+id/widget_break_block_icon"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_break_label_root"
android:layout_width="match_parent"
android:layout_height="14dp"
android:layout_marginTop="0dp">
<ImageView
android:id="@+id/widget_break_label_icon"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:src="@drawable/widget_icon_coffee" />
</FrameLayout>
+40 -13
View File
@@ -1,16 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind <!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
the actual widget content without disturbing the LinearLayout flow. --> the actual widget content without disturbing the LinearLayout flow.
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
height from them, so they must not follow the system font scale. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root" android:id="@+id/widget_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/app_widget_background"> android:background="@drawable/app_widget_background"
tools:ignore="SpUsage,SmallSp">
<!-- Bottom-leading so the mark looks like it's peeking out of the lower <!-- Width/height/end+bottom margins are overridden in the renderer so
left corner. Width/height/start+bottom margins are overridden in the the mark scales with the widget size. -->
renderer so the mark scales with the widget size. -->
<ImageView <ImageView
android:id="@+id/widget_watermark" android:id="@+id/widget_watermark"
android:layout_width="160dp" android:layout_width="160dp"
@@ -26,43 +28,68 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="10dp"> android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="20dp"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
<TextView <TextView
android:id="@+id/widget_day_title" android:id="@+id/widget_day_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_primary"
android:singleLine="true" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="9dp"
android:autoSizeMaxTextSize="14dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="Heute · 08.05." /> tools:text="Heute · 08.05." />
<TextView <TextView
android:id="@+id/widget_day_subtitle" android:id="@+id/widget_day_subtitle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="10sp" android:layout_marginStart="6dp"
android:textSize="10dp"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" /> tools:text="Stand: 14:32" />
</LinearLayout> </LinearLayout>
<!-- Narrow day widgets can't fit title and freshness side by side —
the renderer moves the freshness label down here instead. -->
<TextView
android:id="@+id/widget_day_subtitle_below"
android:layout_width="match_parent"
android:layout_height="12dp"
android:visibility="gone"
android:gravity="start|center_vertical"
android:textSize="9dp"
android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginTop="6dp" android:layout_marginTop="4dp"
android:orientation="horizontal"> android:orientation="horizontal">
<FrameLayout <FrameLayout
android:id="@+id/widget_day_time_labels" android:id="@+id/widget_day_time_labels"
android:layout_width="32dp" android:layout_width="30dp"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
@@ -1,8 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Static picker preview of the day widget, laid out like WidgetRenderer's output. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/widget_background"> android:background="@color/widget_background"
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
<ImageView <ImageView
android:layout_width="160dp" android:layout_width="160dp"
@@ -20,164 +23,126 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="10dp"> android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="20dp"
android:orientation="horizontal" android:gravity="start|center_vertical"
android:gravity="center_vertical"> android:includeFontPadding="false"
<TextView android:textSize="14dp"
android:layout_width="0dp" android:textStyle="bold"
android:layout_height="wrap_content" android:textColor="@color/widget_text_primary"
android:layout_weight="1" android:maxLines="1"
android:textSize="14sp" android:text="Heute · 06.05." />
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="Heute · 06.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:orientation="horizontal" android:layout_marginTop="4dp"
android:layout_marginTop="6dp"> android:orientation="horizontal">
<FrameLayout <FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
android:layout_width="32dp" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
android:layout_height="match_parent"> <FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" />
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp" <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
android:layout_marginTop="22dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="1." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="50dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="2." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="79dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="3." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="115dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="4." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="142dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="5." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="169dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="6." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
</LinearLayout>
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="29dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="56dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="83dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="115dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="143dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="170dp" android:background="@color/widget_divider" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="22dp"
android:layout_marginTop="29dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="MA-LK" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="2" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:25" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="72dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="3" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:15" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp">
<ImageView android:layout_width="10dp" android:layout_height="10dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_text_secondary" android:src="@drawable/widget_icon_coffee" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="122dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:20" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="158dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:10" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="194dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:00" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="230dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:05" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:50" />
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<FrameLayout <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
android:layout_width="match_parent" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
android:layout_height="55dp" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
android:layout_marginTop="83dp" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
android:layout_marginStart="2dp" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
android:layout_marginEnd="2dp" <FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
android:padding="3dp" <ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
android:background="@drawable/widget_lesson_block_regular"> </FrameLayout>
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
android:orientation="vertical"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
android:textSize="11sp" android:textStyle="bold" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
android:textColor="@android:color/white" android:text="DE-GK" /> <FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
android:textSize="9sp" android:textColor="#CCFFFFFF" android:text="B11" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<FrameLayout <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
android:layout_width="match_parent" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
android:layout_height="22dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
android:layout_marginTop="143dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
android:layout_marginStart="2dp" </LinearLayout>
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_cancelled">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="BIO" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<FrameLayout <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
android:layout_width="match_parent" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
android:layout_height="22dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
android:layout_marginTop="170dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
android:layout_marginStart="2dp" </LinearLayout>
android:layout_marginEnd="2dp" </FrameLayout>
android:padding="3dp" <FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#000000">
android:background="@drawable/widget_lesson_block_teacher_changed"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
android:textSize="11sp" android:textStyle="bold" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
android:textColor="@android:color/white" android:text="GE" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
</LinearLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="230.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#29639B">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
</LinearLayout> </LinearLayout>
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
holiday name rotated along the column. The name's width is set by the
renderer — after the rotation it is the length along the column. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_holiday_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:ignore="SpUsage,SmallSp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/widget_holiday_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:src="@drawable/widget_icon_event" />
<TextView
android:id="@+id/widget_holiday_label"
android:layout_width="match_parent"
android:layout_height="12dp"
android:layout_marginTop="2dp"
android:paddingStart="2dp"
android:paddingEnd="2dp"
android:gravity="center"
android:text="@string/widget_school_free"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="6dp"
android:autoSizeMaxTextSize="9dp"
android:autoSizeStepGranularity="0.5dp" />
</LinearLayout>
<TextView
android:id="@+id/widget_holiday_name"
android:layout_width="120dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:rotation="90"
android:gravity="center"
android:textStyle="bold"
android:letterSpacing="0.1"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:ellipsize="end"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="7dp"
android:autoSizeMaxTextSize="11dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="Herbstferien" />
</FrameLayout>
@@ -1,90 +1,113 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Mirrors AppointmentTile: title on top, room and teacher stacked below,
each line shrinking on its own to fit the tile width. Text sizes are dp
(not sp) because the line boxes are sized in dp by the renderer. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_lesson_block_root" android:id="@+id/widget_lesson_block_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="40dp" android:layout_height="40dp"
android:layout_marginStart="2dp" android:layout_marginStart="1dp"
android:layout_marginEnd="2dp" android:layout_marginEnd="1dp"
android:layout_marginTop="0dp" android:layout_marginTop="0dp"
android:paddingStart="7dp" android:background="@drawable/widget_lesson_block"
android:paddingEnd="7dp" tools:ignore="SpUsage,SmallSp">
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:background="@drawable/widget_lesson_block_regular">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="horizontal" android:orientation="vertical"
android:gravity="top" android:paddingStart="4dp"
android:baselineAligned="false"> android:paddingEnd="4dp"
android:paddingTop="2dp"
android:paddingBottom="2dp">
<LinearLayout
android:id="@+id/widget_lesson_title_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:baselineAligned="false">
<TextView
android:id="@+id/widget_lesson_subject"
android:layout_width="0dp"
android:layout_height="14dp"
android:layout_weight="1"
android:fontFamily="sans-serif-medium"
android:textColor="@android:color/white"
android:maxLines="1"
android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="6.5dp"
android:autoSizeMaxTextSize="15dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="MA-LK" />
<!-- Only for tiles too short for a body line below the title. -->
<TextView
android:id="@+id/widget_lesson_inline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="3dp"
android:textColor="@android:color/white"
android:textSize="7dp"
android:maxLines="1"
android:includeFontPadding="false"
android:visibility="gone"
tools:text="B11" />
</LinearLayout>
<TextView <TextView
android:id="@+id/widget_lesson_subject" android:id="@+id/widget_lesson_room"
android:layout_width="0dp" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="10dp"
android:layout_weight="1"
android:textStyle="bold"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="top|start" android:gravity="start|center_vertical"
android:includeFontPadding="false" android:includeFontPadding="false"
android:autoSizeTextType="uniform" android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp" android:autoSizeMinTextSize="5.5dp"
android:autoSizeMaxTextSize="11sp" android:autoSizeMaxTextSize="10dp"
android:autoSizeStepGranularity="1sp" android:autoSizeStepGranularity="0.5dp"
tools:text="MA-LK" /> tools:text="A12" />
<LinearLayout <TextView
android:id="@+id/widget_lesson_secondary_stack" android:id="@+id/widget_lesson_teacher"
android:layout_width="0dp" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="10dp"
android:layout_weight="1" android:textColor="@android:color/white"
android:orientation="vertical" android:maxLines="1"
android:gravity="top|end" android:ellipsize="end"
android:layout_marginStart="4dp"> android:gravity="start|center_vertical"
android:includeFontPadding="false"
<TextView android:autoSizeTextType="uniform"
android:id="@+id/widget_lesson_room" android:autoSizeMinTextSize="5.5dp"
android:layout_width="wrap_content" android:autoSizeMaxTextSize="10dp"
android:layout_height="12dp" android:autoSizeStepGranularity="0.5dp"
android:textColor="#CCFFFFFF" tools:text="Müller" />
android:maxLines="1"
android:ellipsize="end"
android:gravity="end|top"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="A12" />
<TextView
android:id="@+id/widget_lesson_teacher"
android:layout_width="wrap_content"
android:layout_height="12dp"
android:textColor="#B3FFFFFF"
android:maxLines="1"
android:ellipsize="end"
android:gravity="end|top"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="Müller" />
</LinearLayout>
</LinearLayout> </LinearLayout>
<FrameLayout
android:id="@+id/widget_lesson_cancelled"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_lesson_cancelled_overlay"
android:visibility="gone" />
<TextView <TextView
android:id="@+id/widget_lesson_sibling_badge" android:id="@+id/widget_lesson_sibling_badge"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|start" android:layout_gravity="bottom|end"
android:layout_marginEnd="4dp"
android:layout_marginBottom="2dp"
android:textStyle="bold" android:textStyle="bold"
android:textSize="12sp" android:textSize="9dp"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:maxLines="1" android:maxLines="1"
android:includeFontPadding="false" android:includeFontPadding="false"
@@ -1,31 +1,50 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <!-- One period cell of the ruler, mirroring the in-app _PeriodLabel: number
centred, start time above, end time below. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_time_label_root" android:id="@+id/widget_time_label_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="36dp"
android:layout_marginTop="0dp" android:layout_marginTop="0dp"
android:orientation="vertical" tools:ignore="SpUsage,SmallSp">
android:gravity="end"
android:paddingEnd="3dp">
<TextView <TextView
android:id="@+id/widget_time_label_time" android:id="@+id/widget_time_label_start"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="9sp" android:layout_gravity="top"
android:textColor="@color/widget_text_primary" android:layout_marginTop="2dp"
android:singleLine="true" android:gravity="center_horizontal"
android:lineSpacingExtra="-2dp" android:textSize="7dp"
android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="07:55" /> tools:text="07:55" />
<TextView <TextView
android:id="@+id/widget_time_label_number" android:id="@+id/widget_time_label_number"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:layout_gravity="center_vertical"
android:gravity="center_horizontal"
android:fontFamily="sans-serif-medium"
android:textSize="11dp"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="1" />
<TextView
android:id="@+id/widget_time_label_end"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="2dp"
android:gravity="center_horizontal"
android:textSize="7dp"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_secondary"
android:singleLine="true" android:maxLines="1"
android:lineSpacingExtra="-2dp" android:includeFontPadding="false"
tools:text="1." /> tools:text="08:40" />
</LinearLayout> </FrameLayout>
+36 -18
View File
@@ -1,11 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
the actual widget content without disturbing the LinearLayout flow.
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
height from them, so they must not follow the system font scale. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root" android:id="@+id/widget_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/app_widget_background"> android:background="@drawable/app_widget_background"
tools:ignore="SpUsage,SmallSp">
<!-- Width/height/end+bottom margins are overridden in the renderer so
the mark scales with the widget size. -->
<ImageView <ImageView
android:id="@+id/widget_watermark" android:id="@+id/widget_watermark"
android:layout_width="160dp" android:layout_width="160dp"
@@ -21,42 +28,53 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="10dp"> android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="20dp"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
<TextView <TextView
android:id="@+id/widget_week_title" android:id="@+id/widget_week_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_primary"
android:singleLine="true" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="9dp"
android:autoSizeMaxTextSize="14dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="KW 19 · 06.05.–10.05." /> tools:text="KW 19 · 06.05.–10.05." />
<TextView <TextView
android:id="@+id/widget_week_subtitle" android:id="@+id/widget_week_subtitle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="10sp" android:layout_marginStart="6dp"
android:textSize="10dp"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" /> tools:text="Stand: 14:32" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="32dp"
android:orientation="horizontal" android:layout_marginTop="4dp"
android:layout_marginTop="6dp"> android:orientation="horizontal">
<FrameLayout <FrameLayout
android:layout_width="28dp" android:layout_width="30dp"
android:layout_height="wrap_content" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
android:layout_width="1dp" android:layout_width="1dp"
@@ -66,7 +84,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_mon" android:id="@+id/widget_week_header_mon"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -77,7 +95,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_tue" android:id="@+id/widget_week_header_tue"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -88,7 +106,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_wed" android:id="@+id/widget_week_header_wed"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -99,7 +117,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_thu" android:id="@+id/widget_week_header_thu"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -110,7 +128,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_fri" android:id="@+id/widget_week_header_fri"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
</LinearLayout> </LinearLayout>
@@ -123,7 +141,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_time_labels" android:id="@+id/widget_week_time_labels"
android:layout_width="28dp" android:layout_width="30dp"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
@@ -1,28 +1,35 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
today inside an accent circle. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_week_day_header_root" android:id="@+id/widget_week_day_header_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center" android:gravity="center"
android:paddingTop="2dp" tools:ignore="SpUsage,SmallSp">
android:paddingBottom="3dp">
<TextView <TextView
android:id="@+id/widget_week_day_header_weekday" android:id="@+id/widget_week_day_header_weekday"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:fontFamily="sans-serif-medium"
android:textStyle="bold" android:textSize="9dp"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_secondary"
tools:text="Mo" /> android:maxLines="1"
android:includeFontPadding="false"
tools:text="MO." />
<TextView <TextView
android:id="@+id/widget_week_day_header_date" android:id="@+id/widget_week_day_header_date"
android:layout_width="wrap_content" android:layout_width="18dp"
android:layout_height="wrap_content" android:layout_height="18dp"
android:textSize="9sp" android:layout_marginTop="1dp"
android:textColor="@color/widget_text_secondary" android:gravity="center"
tools:text="06.05." /> android:textSize="11dp"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="6" />
</LinearLayout> </LinearLayout>
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Static preview for the week widget. Five day columns with two short <!-- Static picker preview of the week widget, laid out like WidgetRenderer's output. -->
demo blocks each so the structure is recognisable in the picker. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/widget_background"> android:background="@color/widget_background"
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
<ImageView <ImageView
android:layout_width="200dp" android:layout_width="200dp"
@@ -22,124 +23,312 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="10dp"> android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<TextView
android:layout_width="match_parent"
android:layout_height="20dp"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:textSize="14dp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:text="KW 19 · 04.05.–08.05." />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="32dp"
android:orientation="horizontal" android:layout_marginTop="4dp"
android:gravity="center_vertical"> android:orientation="horizontal">
<TextView <FrameLayout android:layout_width="30dp" android:layout_height="match_parent" />
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="KW 19 · 06.05.–10.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<!-- Day-name + date row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp">
<FrameLayout android:layout_width="20dp" android:layout_height="wrap_content" />
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mo" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MO." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="06.05." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Di" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DI." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="07.05." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mi" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MI." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="08.05." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Do" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DO." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="09.05." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Fr" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="FR." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="10.05." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="8" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<!-- Time grid: time-label column + 5 day columns -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:orientation="horizontal" android:layout_marginTop="2dp"
android:layout_marginTop="2dp"> android:orientation="horizontal">
<FrameLayout android:layout_width="20dp" android:layout_height="match_parent"> <FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="0dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="08" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="36dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="10" /> <FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="72dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="12" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
</FrameLayout> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" />
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
<!-- Mon -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" /> <FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="2" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:25" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="72dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="3" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:15" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp">
<ImageView android:layout_width="10dp" android:layout_height="10dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_text_secondary" android:src="@drawable/widget_icon_coffee" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="122dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:20" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="158dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:10" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="194dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:00" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="230dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:05" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:50" />
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Tue -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_cancelled"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="BIO" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="60dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="DE" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#000000">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
</LinearLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" />
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Wed -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="32dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#26A69A">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="SPO" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="TH1" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BAU" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#8F19B3">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PoWi" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A105" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="HOF" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="84.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#29639B">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Thu -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_teacher_changed"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="GE" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="PH" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#E91E63">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="CH" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N305" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MEI" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="194.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Fri -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#78909C">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A301" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LAN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#7E57C2">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MU" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="M1" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SIN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#F4511E">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="KU" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="K2" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MAL" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#FFA000">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="LA" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A108" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="CIC" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
</LinearLayout> </LinearLayout>
@@ -4,4 +4,6 @@
<color name="widget_text_primary">#FFF1F1F1</color> <color name="widget_text_primary">#FFF1F1F1</color>
<color name="widget_text_secondary">#FFB0B0B0</color> <color name="widget_text_secondary">#FFB0B0B0</color>
<color name="widget_divider">#33FFFFFF</color> <color name="widget_divider">#33FFFFFF</color>
<color name="widget_preview_accent">#FFFFB3AF</color>
<color name="widget_preview_break">#32FFB3AF</color>
</resources> </resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="widget_supported">true</bool>
</resources>
@@ -6,6 +6,7 @@
<string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string> <string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string>
<string name="widget_no_lessons">Keine Stunden</string> <string name="widget_no_lessons">Keine Stunden</string>
<string name="widget_holiday">Ferien</string> <string name="widget_holiday">Ferien</string>
<string name="widget_school_free">Schulfrei</string>
<string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string> <string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string>
<string name="widget_loading">Lade…</string> <string name="widget_loading">Lade…</string>
<string name="widget_status_label">Stand: %1$s</string> <string name="widget_status_label">Stand: %1$s</string>
@@ -1,17 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Status colors mirror lib/view/pages/timetable/data/lesson_color.dart
exactly so widget tiles match the in-app calendar. -->
<color name="widget_lesson_regular">#FF993333</color>
<color name="widget_lesson_ongoing">#FFC83333</color>
<color name="widget_lesson_past">#FF993333</color>
<color name="widget_lesson_cancelled">#FF000000</color>
<color name="widget_lesson_irregular">#FF8F19B3</color>
<color name="widget_lesson_teacher_changed">#FF29639B</color>
<color name="widget_lesson_event">#FF2E7D32</color>
<color name="widget_background">#FFFCF7F5</color> <color name="widget_background">#FFFCF7F5</color>
<color name="widget_text_primary">#FF111111</color> <color name="widget_text_primary">#FF111111</color>
<color name="widget_text_secondary">#FF555555</color> <color name="widget_text_secondary">#FF555555</color>
<color name="widget_divider">#22000000</color> <color name="widget_divider">#22000000</color>
<color name="widget_preview_accent">#FF904A47</color>
<color name="widget_preview_break">#32904A47</color>
</resources> </resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- WidgetRenderer positions every tile with RemoteViews calls that only
exist from Android 12 (API 31). Below that the widgets are not
offered at all rather than showing up empty. -->
<bool name="widget_supported">false</bool>
</resources>
+1
View File
@@ -4,4 +4,5 @@ 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
+21 -2
View File
@@ -17,7 +17,8 @@ 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
/// (Phone / 7″ / 10″). /// (Android: Phone / 7″ / 10″) bzw. tool/screenshots_ios.sh (iPhone 6,5″ /
/// 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.
@@ -42,11 +43,13 @@ 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: 20), timeout: const Duration(seconds: 60),
); );
_log('warte auf Post-Login-Splash-Ende'); _log('warte auf Post-Login-Splash-Ende');
await _pumpUntilGone( await _pumpUntilGone(
@@ -64,6 +67,12 @@ 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);
@@ -129,6 +138,16 @@ 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 | `account_data.dart` | **AppDelegate** (Basic-Auth) | | `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) |
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) | | `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) | | `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage > `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE > **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei > unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das > jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
+2 -8
View File
@@ -12,8 +12,6 @@ PODS:
- PhoneNumberKit/PhoneNumberKitCore (3.7.11) - PhoneNumberKit/PhoneNumberKitCore (3.7.11)
- PhoneNumberKit/UIKit (3.7.11): - PhoneNumberKit/UIKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore - PhoneNumberKit/PhoneNumberKitCore
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- eraser (from `.symlinks/plugins/eraser/ios`) - eraser (from `.symlinks/plugins/eraser/ios`)
@@ -21,7 +19,6 @@ DEPENDENCIES:
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`) - flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`) - open_filex (from `.symlinks/plugins/open_filex/ios`)
- PhoneNumberKit (~> 3.7.6) - PhoneNumberKit (~> 3.7.6)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
@@ -36,17 +33,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_app_badge/ios" :path: ".symlinks/plugins/flutter_app_badge/ios"
open_filex: open_filex:
:path: ".symlinks/plugins/open_filex/ios" :path: ".symlinks/plugins/open_filex/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
eraser: 83a4b06985f3702aa3d8dec816f9693266012937 eraser: 83a4b06985f3702aa3d8dec816f9693266012937
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6 PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
COCOAPODS: 1.16.2 COCOAPODS: 1.17.0
+5 -6
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 54; objectVersion = 70;
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; name = Info.plist; path = Info.plist; sourceTree = "<group>"; }; 12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; }; 17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; }; 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; }; 36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; }; 509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PEM.swift; sourceTree = "<group>"; };
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; }; 5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; }; 60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; 64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
@@ -222,7 +222,6 @@
12B96C78930441C73F123636 /* Info.plist */, 12B96C78930441C73F123636 /* Info.plist */,
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */, 5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
); );
name = NotificationServiceExtension;
path = NotificationServiceExtension; path = NotificationServiceExtension;
sourceTree = SOURCE_ROOT; sourceTree = SOURCE_ROOT;
}; };
@@ -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" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", "revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a",
"version" : "11.3.1" "version" : "11.3.2"
} }
}, },
{ {
@@ -50,8 +50,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk", "location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : { "state" : {
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270", "revision" : "27eaab3918e0bf78711cf1abf240577176326432",
"version" : "12.15.0" "version" : "12.19.0"
} }
}, },
{ {
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : { "state" : {
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", "revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.6.1" "version" : "3.7.0"
} }
}, },
{ {
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git", "location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : { "state" : {
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b", "revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
"version" : "12.15.0" "version" : "12.19.2"
} }
}, },
{ {
@@ -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" : "617af071af9aa1d6a091d59a202910ac482128f9", "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974",
"version" : "10.1.0" "version" : "10.1.1"
} }
}, },
{ {
@@ -86,8 +86,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git", "location" : "https://github.com/google/GoogleUtilities.git",
"state" : { "state" : {
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.2" "version" : "8.1.3"
} }
}, },
{ {
@@ -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" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc",
"version" : "5.3.0" "version" : "5.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" : "42e81d245e30e49ea6a5830cf2842d44a1591270", "revision" : "27eaab3918e0bf78711cf1abf240577176326432",
"version" : "12.15.0" "version" : "12.19.0"
} }
}, },
{ {
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : { "state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", "revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.6.0" "version" : "3.7.0"
} }
}, },
{ {
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git", "location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : { "state" : {
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b", "revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
"version" : "12.15.0" "version" : "12.19.2"
} }
}, },
{ {
@@ -86,8 +86,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git", "location" : "https://github.com/google/GoogleUtilities.git",
"state" : { "state" : {
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.1" "version" : "8.1.3"
} }
}, },
{ {
+86 -86
View File
@@ -1,94 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupId</key> <key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string> <string>$(CUSTOM_GROUP_ID)</string>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>BGTaskSchedulerPermittedIdentifiers</key>
<true/> <array>
<key>CFBundleDevelopmentRegion</key> <string>eu.mhsl.marianum.widget.refresh</string>
<string>$(DEVELOPMENT_LANGUAGE)</string> </array>
<key>CFBundleDisplayName</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<string>Marianum Fulda</string> <true/>
<key>CFBundleExecutable</key> <key>CFBundleDevelopmentRegion</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleIdentifier</key> <key>CFBundleDisplayName</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>Marianum Fulda</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleExecutable</key>
<string>6.0</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleName</key> <key>CFBundleIdentifier</key>
<string>client</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundlePackageType</key> <key>CFBundleInfoDictionaryVersion</key>
<string>APPL</string> <string>6.0</string>
<key>CFBundleShortVersionString</key> <key>CFBundleName</key>
<string>$(FLUTTER_BUILD_NAME)</string> <string>client</string>
<key>CFBundleSignature</key> <key>CFBundlePackageType</key>
<string>????</string> <string>APPL</string>
<key>CFBundleURLTypes</key> <key>CFBundleShortVersionString</key>
<array> <string>$(FLUTTER_BUILD_NAME)</string>
<dict> <key>CFBundleSignature</key>
<key>CFBundleTypeRole</key> <string>????</string>
<string>Editor</string> <key>CFBundleURLTypes</key>
<key>CFBundleURLSchemes</key> <array>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
<key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>CFBundleTypeRole</key>
<false/> <string>Editor</string>
<key>UISceneConfigurations</key> <key>CFBundleURLSchemes</key>
<dict> <array>
<key>UIWindowSceneSessionRoleApplication</key> <string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<array> </array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict> </dict>
<key>UIApplicationSupportsIndirectInputEvents</key> </array>
<true/> <key>CFBundleVersion</key>
<key>BGTaskSchedulerPermittedIdentifiers</key> <string>$(FLUTTER_BUILD_NUMBER)</string>
<array> <key>LSRequiresIPhoneOS</key>
<string>eu.mhsl.marianum.widget.refresh</string> <true/>
</array> <key>NSCameraUsageDescription</key>
<key>UIBackgroundModes</key> <string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
<array> <key>NSPhotoLibraryUsageDescription</key>
<string>fetch</string> <string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
<string>remote-notification</string> <key>UIApplicationSceneManifest</key>
</array> <dict>
<key>UILaunchStoryboardName</key> <key>UIApplicationSupportsMultipleScenes</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict> </dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist> </plist>
+90 -17
View File
@@ -107,20 +107,58 @@ 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 { for (typeId, kind) in order where provider.hasItemConformingToTypeIdentifier(typeId) {
if provider.hasItemConformingToTypeIdentifier(typeId) { // Konkreter Typ (z. B. public.png statt public.image) für Dateiendung
provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in // und MIME-Type, falls die Quelle nur rohe Daten liefert.
guard let self else { completion(nil); return } let concrete = concreteType(of: provider, conformingTo: typeId)
if error != nil { completion(nil); return } provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in
completion(self.toSharedFile(data: data, kind: kind)) guard let self else { completion(nil); return }
if error == nil,
let file = self.toSharedFile(data: data, kind: kind,
concrete: concrete, provider: provider) {
completion(file)
return
} }
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
} }
completion(nil) completion(nil)
} }
private func toSharedFile(data: Any?, kind: SharedMediaType) -> SharedMediaFile? { private func concreteType(of provider: NSItemProvider, conformingTo typeId: String) -> UTType? {
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 }
@@ -139,6 +177,10 @@ 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 }
@@ -146,9 +188,15 @@ 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:
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil } if let u = data as? URL, let dst = copyIntoAppGroup(src: u) {
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
} }
} }
@@ -192,13 +240,11 @@ final class ShareViewController: UIViewController {
} }
private func copyIntoShareDir(src: URL) -> URL? { private func copyIntoShareDir(src: URL) -> URL? {
guard let dir = shareDir else { return nil }
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
var dst = dir.appendingPathComponent(name) guard let dst = uniqueDestination(name: name) else { return nil }
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares. // Security-scoped URLs (z. B. aus "Dateien") sind sonst nicht lesbar.
if FileManager.default.fileExists(atPath: dst.path) { let scoped = src.startAccessingSecurityScopedResource()
dst = dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)") defer { if scoped { src.stopAccessingSecurityScopedResource() } }
}
do { do {
try FileManager.default.copyItem(at: src, to: dst) try FileManager.default.copyItem(at: src, to: dst)
return dst return dst
@@ -207,6 +253,33 @@ 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 data = image.pngData(),
let dir = fileQueue.sync(execute: { shareDir }) else { return nil } let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
@@ -1,59 +1,6 @@
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
// Layout constants — must mirror WidgetRenderer.kt on Android, otherwise
// the platforms drift apart on the same widget size.
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
let MIN_HOUR_HEIGHT: CGFloat = 18
let MAX_HOUR_HEIGHT: CGFloat = 72
let MIN_BLOCK_HEIGHT: CGFloat = 16
let LESSON_GAP: CGFloat = 1.5
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
guard !periods.isEmpty else { return CGFloat(realMin) }
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
}
let first = periods.first!
if realMin < first.startMinutes {
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
}
let last = periods.last!
if realMin > last.endMinutes {
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
}
var prev = first
for i in 1..<periods.count {
let curr = periods[i]
if realMin > prev.endMinutes && realMin < curr.startMinutes {
let gap = curr.startMinutes - prev.endMinutes
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
if gap > 0 {
return CGFloat(prev.virtualEndMinutes) +
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
}
return CGFloat(curr.virtualStartMinutes)
}
prev = curr
}
return 0
}
// Below these tile heights the secondary stack collapses progressively:
// shorter than ROOM_MIN drops everything, between ROOM_MIN and
// TEACHER_SEPARATE_MIN keeps the room but drops the teacher.
let BLOCK_SHOW_ROOM_MIN: CGFloat = 13
let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 20
let MIN_SUBJECT_FONT: CGFloat = 9
let MAX_SUBJECT_FONT: CGFloat = 14
let MIN_SECONDARY_FONT: CGFloat = 7
func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat {
let t = max(0, min(1, (hourHeight - MIN_HOUR_HEIGHT) / (MAX_HOUR_HEIGHT - MIN_HOUR_HEIGHT)))
return MIN_SUBJECT_FONT + t * (MAX_SUBJECT_FONT - MIN_SUBJECT_FONT)
}
struct TimetableDayView: View { struct TimetableDayView: View {
let entry: TimetableEntry let entry: TimetableEntry
@Environment(\.widgetPalette) private var palette @Environment(\.widgetPalette) private var palette
@@ -73,7 +20,7 @@ struct TimetableDayView: View {
@ViewBuilder @ViewBuilder
private func content(data: WidgetTimetableData) -> some View { private func content(data: WidgetTimetableData) -> some View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) {
header(data: data) header(data: data)
if data.isHoliday { if data.isHoliday {
emptyState(text: data.holidayName ?? "Ferien") emptyState(text: data.holidayName ?? "Ferien")
@@ -81,37 +28,41 @@ struct TimetableDayView: View {
emptyState(text: "Keine Stunden") emptyState(text: "Keine Stunden")
} else { } else {
GeometryReader { geo in GeometryReader { geo in
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) let periods = trimTrailingPeriods(data.periods, lessons: data.lessons)
TimeGridView( HStack(alignment: .top, spacing: 0) {
lessons: data.lessons, PeriodRuler(
periods: data.periods, periods: periods,
hourHeight: max( hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
MIN_HOUR_HEIGHT, )
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) Rectangle()
), .fill(palette.divider)
showRoom: true, .frame(width: 1)
showTeacher: true, TimeGridView(
showTimeLabels: true lessons: data.lessons,
) periods: periods,
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
)
}
} }
} }
} }
} }
private func header(data: WidgetTimetableData) -> some View { private func header(data: WidgetTimetableData) -> some View {
HStack(spacing: 4) { HStack(spacing: 6) {
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date)) Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 14, weight: .bold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .minimumScaleFactor(0.65)
Spacer(minLength: 4) .frame(maxWidth: .infinity, alignment: .leading)
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9)) .font(.system(size: 10))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .fixedSize()
} }
.frame(height: TITLE_ROW_HEIGHT)
} }
private func emptyState(text: String) -> some View { private func emptyState(text: String) -> some View {
@@ -139,54 +90,23 @@ struct TimetableDayView: View {
} }
} }
struct TimeGridView: View { /// Mirrors the in-app _PeriodLabel: period number centred, start time above,
let lessons: [WidgetLesson] /// end time below, a cup in the breaks.
struct PeriodRuler: View {
let periods: [WidgetPeriod] let periods: [WidgetPeriod]
let hourHeight: CGFloat let hourHeight: CGFloat
let showRoom: Bool
let showTeacher: Bool
let showTimeLabels: Bool
/// Week-widget passes 3 for narrow columns; day-widget keeps 7.
var horizontalPadding: CGFloat = 7
@Environment(\.widgetPalette) private var palette @Environment(\.widgetPalette) private var palette
private var totalVirtualMinutes: Int {
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
}
private var totalHeight: CGFloat { private var totalHeight: CGFloat {
CGFloat(totalVirtualMinutes) * hourHeight / 60.0 CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
} }
/// Below this per-hour height the two-line label collapses to a single
/// period number — time + number overlap otherwise.
private var compactLabels: Bool { hourHeight < 26 }
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 0) { // Every child has layout-y 0 (`.offset` only shifts the rendering),
if showTimeLabels { // so both the ZStack and its frame need `.top` — the default
// 28pt matches the Week widget's time-label column so the // `.center` would anchor the whole ruler at the column's midpoint.
// visual rhythm is identical across both widgets. ZStack(alignment: .top) {
timeLabelsColumn
.frame(width: 28, alignment: .topTrailing)
Rectangle()
.fill(palette.divider)
.frame(width: 1)
}
ZStack(alignment: .top) {
gridLines
breakBlocks
ForEach(lessons.indices, id: \.self) { idx in
lessonBlock(lessons[idx])
}
}
.frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
}
}
private var timeLabelsColumn: some View {
ZStack(alignment: .topTrailing) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle() Rectangle()
.fill(palette.divider) .fill(palette.divider)
@@ -194,39 +114,91 @@ struct TimeGridView: View {
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
} }
ForEach(periods, id: \.startMinutes) { period in ForEach(periods, id: \.startMinutes) { period in
VStack(alignment: .trailing, spacing: -2) { cell(period)
if compactLabels { }
Text("\(period.name).") ForEach(periodBreaks(periods), id: \.self) { pause in
.font(.system(size: 9, weight: .bold)) let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
.foregroundStyle(palette.textPrimary) if height >= BREAK_ICON_MIN_HEIGHT {
.lineLimit(1) Image(systemName: "cup.and.saucer")
} else { .font(.system(size: 8))
Text(formatHm(period.startMinutes)) .foregroundStyle(palette.textSecondary)
.font(.system(size: 9)) .frame(width: RULER_WIDTH, height: height)
.foregroundStyle(palette.textPrimary) .offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
.lineLimit(1)
Text("\(period.name).")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
} }
.padding(.trailing, 4)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
} }
} }
.frame(height: totalHeight, alignment: .topTrailing) .frame(width: RULER_WIDTH, height: totalHeight, alignment: .top)
}
private func cell(_ period: WidgetPeriod) -> some View {
let height = CGFloat(period.virtualEndMinutes - period.virtualStartMinutes) * hourHeight / 60.0
let timeFont = max(6, min(8.5, height * 0.19))
return ZStack {
Text(period.name)
.font(.system(size: max(8, min(13, height * 0.3)), weight: .medium))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
if height >= RULER_TIMES_MIN_CELL {
VStack(spacing: 0) {
Text(formatHm(period.startMinutes))
Spacer(minLength: 0)
Text(formatHm(period.endMinutes))
}
.font(.system(size: timeFont))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.padding(.vertical, 2)
}
}
.frame(width: RULER_WIDTH, height: height)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
}
private func formatHm(_ minutes: Int) -> String {
String(format: "%02d:%02d", minutes / 60, minutes % 60)
}
}
/// One day column: grid lines, break bands and the lesson tiles. Shared by
/// the day widget and each of the week widget's five columns.
struct TimeGridView: View {
let lessons: [WidgetLesson]
let periods: [WidgetPeriod]
let hourHeight: CGFloat
var holiday: WidgetDayInfo? = nil
var isToday: Bool = false
@Environment(\.widgetPalette) private var palette
private var totalHeight: CGFloat {
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
}
var body: some View {
GeometryReader { geo in
let metrics = TileMetrics(periods: periods, hourHeight: hourHeight)
let innerWidth = geo.size.width - 2 * (TILE_MARGIN_H + TILE_PADDING_H)
ZStack(alignment: .top) {
if let holiday {
holidayColumn(holiday)
} else {
if isToday {
palette.todayTint.frame(height: totalHeight)
}
gridLines
breakBlocks
}
ForEach(lessons.indices, id: \.self) { idx in
lessonBlock(lessons[idx], metrics: metrics, innerWidth: innerWidth)
}
}
.frame(width: geo.size.width, height: totalHeight, alignment: .top)
}
.frame(height: totalHeight)
} }
private var gridLines: some View { private var gridLines: some View {
// `.frame(height: totalHeight, alignment: .top)` — without the // `.top` on both the ZStack and the frame — see PeriodRuler.
// explicit `.top`, SwiftUI's frame modifier defaults to `.center`.
// Every Rectangle here has layout-y=0 (the `.offset()`s shift the
// rendered position only, not layout), so the inner ZStack's
// natural height is just 1pt; a 1pt ZStack centred in a 300pt
// frame anchors at the middle, and every line then renders
// relative to that midpoint — the grid visually starts halfway
// down the column and the later periods clip past the bottom.
ZStack(alignment: .top) { ZStack(alignment: .top) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle() Rectangle()
@@ -239,166 +211,173 @@ struct TimeGridView: View {
} }
private var breakBlocks: some View { private var breakBlocks: some View {
// Same `.center`-vs-`.top` bug as gridLines — see comment above.
ZStack(alignment: .top) { ZStack(alignment: .top) {
ForEach(0..<max(0, periods.count - 1), id: \.self) { i in ForEach(periodBreaks(periods), id: \.self) { pause in
let curr = periods[i] let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
let next = periods[i + 1] ZStack {
let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes palette.breakBlock
if virtualGap > 0 { if height >= BREAK_ICON_MIN_HEIGHT {
Rectangle() Image(systemName: "fork.knife")
.fill(palette.breakBlock) .font(.system(size: 9))
.frame(height: CGFloat(virtualGap) * hourHeight / 60.0) .foregroundStyle(palette.accent)
.padding(.horizontal, 1) }
.offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0)
} }
.frame(height: height)
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
} }
} }
.frame(height: totalHeight, alignment: .top) .frame(height: totalHeight, alignment: .top)
} }
private func formatHm(_ minutes: Int) -> String { /// Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
String(format: "%02d:%02d", minutes / 60, minutes % 60) /// holiday name rotated along the column.
private func holidayColumn(_ holiday: WidgetDayInfo) -> some View {
let nameLength = max(40, totalHeight - 2 * HOLIDAY_NAME_INSET)
return ZStack {
palette.holidayTint
VStack(spacing: 2) {
Image(systemName: "calendar")
.font(.system(size: 12))
Text("Schulfrei")
.font(.system(size: 9))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.padding(.top, 8)
.padding(.horizontal, 2)
.frame(maxHeight: .infinity, alignment: .top)
// The outer frame carries the rotated footprint: rotationEffect
// alone leaves the layout box at its unrotated size.
Text(holiday.holidayName ?? "")
.font(.system(size: 11, weight: .bold))
.kerning(1)
.lineLimit(1)
.minimumScaleFactor(0.6)
.frame(width: nameLength, height: 14)
.rotationEffect(.degrees(90))
.frame(width: 14, height: nameLength)
}
.foregroundStyle(palette.textPrimary)
.frame(height: totalHeight)
} }
@ViewBuilder @ViewBuilder
private func lessonBlock(_ lesson: WidgetLesson) -> some View { private func lessonBlock(
let cal = Calendar.current _ lesson: WidgetLesson,
let comps = cal.dateComponents([.hour, .minute], from: lesson.start) metrics: TileMetrics,
let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0) innerWidth: CGFloat
) -> some View {
let startMinutes = minutesOfDay(lesson.start)
let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60)) let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60))
let virtualStart = realMinutesToVirtual(startMinutes, periods: periods) let virtualStart = realMinutesToVirtual(startMinutes, periods: periods)
let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods) let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods)
if virtualEnd > virtualStart { if virtualEnd > virtualStart {
// Half the gap above + half below so the grid line under the
// tile stays visible.
let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2 let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2
let height = max( let height = max(
MIN_BLOCK_HEIGHT, MIN_BLOCK_HEIGHT,
(virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP (virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP
) )
let subjectSize = subjectFont(forHourHeight: hourHeight) LessonTile(
let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2) lesson: lesson,
let room = lesson.room height: height,
let teacher = lesson.teacher ?? lesson.originalTeacher metrics: metrics,
let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false) layout: TileTextLayout(
HStack(alignment: .top, spacing: 4) { lesson: lesson,
Text(subjectLabel(lesson)) innerHeight: height - 2 * TILE_PADDING_V,
.font(.system(size: subjectSize, weight: .semibold)) innerWidth: innerWidth,
.foregroundStyle(.white) metrics: metrics
.lineLimit(hasSecondary ? 1 : 2) )
.minimumScaleFactor(0.5) )
if hasSecondary {
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: -1) {
if showRoom && height >= BLOCK_SHOW_ROOM_MIN {
if let room, !room.isEmpty {
Text(room)
.font(.system(size: secondarySize))
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
.minimumScaleFactor(0.5)
}
if showTeacher,
height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN,
let teacher,
!teacher.isEmpty {
Text(teacher)
.font(.system(size: secondarySize))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
.minimumScaleFactor(0.5)
}
}
}
}
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .topLeading)
.frame(height: height, alignment: .topLeading)
.background(blockColor(lesson))
.cornerRadius(6)
.overlay(alignment: .bottomLeading) {
// Separate fixed-size badge so the +N hint stays readable
// when the subject autoshrinks on narrow tiles.
if let count = lesson.siblingCount, count > 0 {
Text("+\(count)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
.padding(.leading, horizontalPadding)
.padding(.bottom, 2)
}
}
.overlay {
// CrossPainter parity: clip cross to the rounded shape so
// the diagonals don't bleed past the corners.
if lesson.status == .cancelled {
ZStack {
RoundedRectangle(cornerRadius: 6)
.stroke(Color.red.opacity(0.78), lineWidth: 1.5)
GeometryReader { geo in
Path { p in
p.move(to: .zero)
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
p.move(to: CGPoint(x: geo.size.width, y: 0))
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
}
.stroke(Color.red.opacity(0.78), lineWidth: 3)
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
.padding(.horizontal, showRoom ? 2 : 1)
.offset(y: top) .offset(y: top)
} }
} }
private func subjectLabel(_ lesson: WidgetLesson) -> String {
!lesson.subjectShort.isEmpty
? lesson.subjectShort
: (lesson.subjectLong ?? "—")
}
/// Mirrors lesson_color.dart + custom_event_colors.dart so the widget
/// matches the in-app calendar exactly.
private func blockColor(_ lesson: WidgetLesson) -> Color {
if lesson.status == .event, let custom = lesson.customColor {
switch custom {
case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0)
case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0)
default: break
}
}
switch lesson.status {
// `.ongoing` deliberately collapses into the regular red — the
// widget timeline only ticks every ~30min, so highlighting the
// "current" lesson would be stale most of the day and misleads
// more than it helps.
case .regular, .past, .ongoing:
return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
case .cancelled: return .black
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
case .duty: return Color(red: 0/255.0, green: 121/255.0, blue: 107/255.0)
}
}
} }
/// Period boundaries deduped: adjacent periods share a line, periods on /// Mirrors the in-app AppointmentTile: title on top, room and teacher
/// either side of a break get their own (bracketing the break block). /// stacked below, each line shrinking on its own to fit the tile width.
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] { private struct LessonTile: View {
var seen = Set<Int>() let lesson: WidgetLesson
var result: [Int] = [] let height: CGFloat
for p in periods { let metrics: TileMetrics
for v in [p.virtualStartMinutes, p.virtualEndMinutes] { let layout: TileTextLayout
if seen.insert(v).inserted { result.append(v) }
private static let cancelledRed =
Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255)
var body: some View {
let shape = RoundedRectangle(cornerRadius: TILE_CORNER_RADIUS)
VStack(alignment: .leading, spacing: 0) {
titleRow
ForEach(Array(layout.body.enumerated()), id: \.offset) { index, line in
Text(line)
.font(.system(size: metrics.bodyFont))
.lineLimit(1)
.minimumScaleFactor(MIN_BODY_LINE_FONT / metrics.bodyFont)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: metrics.bodyBox)
.padding(.trailing, badgeInset(line: index + 1))
}
} }
.foregroundStyle(.white)
.padding(.horizontal, TILE_PADDING_H)
.padding(.vertical, TILE_PADDING_V)
.frame(maxWidth: .infinity, alignment: .topLeading)
.frame(height: height, alignment: .topLeading)
.background(lesson.tileColor)
.clipShape(shape)
.overlay(alignment: .bottomTrailing) {
if let count = lesson.siblingCount, count > 0 {
Text("+\(count)")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.padding(.trailing, TILE_PADDING_H)
.padding(.bottom, TILE_PADDING_V)
}
}
.overlay {
if lesson.status == .cancelled {
ZStack {
shape.strokeBorder(LessonTile.cancelledRed, lineWidth: 2)
GeometryReader { geo in
Path { p in
p.move(to: .zero)
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
p.move(to: CGPoint(x: geo.size.width, y: 0))
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
}
.stroke(LessonTile.cancelledRed, lineWidth: 2)
}
}
.clipShape(shape)
}
}
.padding(.horizontal, TILE_MARGIN_H)
}
private var titleRow: some View {
HStack(alignment: .center, spacing: 3) {
Text(layout.title)
.font(.system(size: layout.titleFont, weight: .medium))
.lineLimit(layout.titleLines)
.minimumScaleFactor(min(1, MIN_TITLE_FONT / layout.titleFont))
.frame(maxWidth: .infinity, alignment: .leading)
if let inline = layout.inline {
Text(inline)
.font(.system(size: metrics.bodyFont))
.lineLimit(1)
.fixedSize()
}
}
.frame(height: layout.titleBox)
.padding(.trailing, badgeInset(line: 0))
}
private func badgeInset(line: Int) -> CGFloat {
layout.badgeLine == line ? BADGE_WIDTH : 0
} }
return result.sorted()
} }
/// Fixed-locale formatters cached once — DateFormatter setup is the /// Fixed-locale formatters cached once — DateFormatter setup is the
@@ -0,0 +1,279 @@
import SwiftUI
import UIKit
// Layout constants and rules — must mirror WidgetRenderer.kt on Android,
// otherwise the platforms drift apart on the same widget size.
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
let FALLBACK_SLOT_MINUTES = 45
/// Trailing periods without lessons are cut off so the tiles get more
/// height — but never below this many periods, or a short day would blow up
/// to a handful of oversized tiles.
let MIN_VISIBLE_PERIODS = 6
let MIN_HOUR_HEIGHT: CGFloat = 18
let MAX_HOUR_HEIGHT: CGFloat = 72
let MIN_BLOCK_HEIGHT: CGFloat = 16
let LESSON_GAP: CGFloat = 1.5
let TITLE_ROW_HEIGHT: CGFloat = 20
let GRID_MARGIN_TOP: CGFloat = 4
let WEEK_HEADER_HEIGHT: CGFloat = 32
let RULER_WIDTH: CGFloat = 30
let TILE_MARGIN_H: CGFloat = 1
let TILE_PADDING_H: CGFloat = 4
let TILE_PADDING_V: CGFloat = 2
let TILE_CORNER_RADIUS: CGFloat = 7
// In-app tile: 15sp title over 10sp body lines. The widget keeps that ratio
// and scales both so title + room + teacher fill one period.
let TITLE_TO_BODY_RATIO: CGFloat = 1.5
let MAX_BODY_FONT: CGFloat = 10
let MIN_BODY_FONT: CGFloat = 6.5
let MIN_TITLE_FONT: CGFloat = 6.5
let MIN_BODY_LINE_FONT: CGFloat = 5.5
let LINE_BOX_PER_FONT: CGFloat = 1.2
/// If stacking room and teacher would push the body font below this, both
/// share one line instead so the text stays readable.
let STACKED_BODY_MIN_FONT: CGFloat = 7.5
/// A title that needs to shrink below this to fit on one line wraps onto a
/// second line instead, if the tile has the height for it.
let TITLE_WRAP_BELOW_FONT: CGFloat = 8
// Footprint of the "+N" badge in the tile's bottom trailing corner.
let BADGE_WIDTH: CGFloat = 16
let BADGE_HEIGHT: CGFloat = 11
let RULER_TIMES_MIN_CELL: CGFloat = 30
let BREAK_ICON_MIN_HEIGHT: CGFloat = 12
/// Space the holiday column keeps free above and below the rotated name for
/// the icon and the "Schulfrei" label.
let HOLIDAY_NAME_INSET: CGFloat = 44
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
guard !periods.isEmpty else { return CGFloat(realMin) }
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
}
let first = periods.first!
if realMin < first.startMinutes {
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
}
let last = periods.last!
if realMin > last.endMinutes {
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
}
var prev = first
for i in 1..<periods.count {
let curr = periods[i]
if realMin > prev.endMinutes && realMin < curr.startMinutes {
let gap = curr.startMinutes - prev.endMinutes
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
if gap > 0 {
return CGFloat(prev.virtualEndMinutes) +
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
}
return CGFloat(curr.virtualStartMinutes)
}
prev = curr
}
return 0
}
func minutesOfDay(_ date: Date) -> Int {
let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
return (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
}
func trimTrailingPeriods(_ periods: [WidgetPeriod], lessons: [WidgetLesson]) -> [WidgetPeriod] {
guard let latestEnd = lessons.map({ minutesOfDay($0.end) }).max() else { return periods }
let lastUsed = periods.lastIndex { $0.startMinutes < latestEnd } ?? -1
return Array(periods.prefix(max(lastUsed + 1, MIN_VISIBLE_PERIODS)))
}
func totalVirtualMinutes(_ periods: [WidgetPeriod]) -> Int {
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
}
func resolveHourHeight(forGridHeight height: CGFloat, periods: [WidgetPeriod]) -> CGFloat {
let virtualHours = max(CGFloat(totalVirtualMinutes(periods)) / 60, 1)
return max(MIN_HOUR_HEIGHT, min(MAX_HOUR_HEIGHT, height / virtualHours))
}
/// Period boundaries deduped: adjacent periods share a line, periods on
/// either side of a break get their own (bracketing the break block).
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
var seen = Set<Int>()
var result: [Int] = []
for p in periods {
for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
if seen.insert(v).inserted { result.append(v) }
}
}
return result.sorted()
}
struct PeriodBreak: Hashable {
let virtualStart: Int
let virtualMinutes: Int
}
/// Only big breaks (Hofpause, Mittagspause) survive the mapper's small-break
/// collapse as a virtual gap between two periods.
func periodBreaks(_ periods: [WidgetPeriod]) -> [PeriodBreak] {
guard periods.count > 1 else { return [] }
return (0..<periods.count - 1).compactMap { i in
let gap = periods[i + 1].virtualStartMinutes - periods[i].virtualEndMinutes
return gap > 0
? PeriodBreak(virtualStart: periods[i].virtualEndMinutes, virtualMinutes: gap)
: nil
}
}
/// Font and line-box sizes shared by every tile of one widget, derived from
/// the height of a single period.
struct TileMetrics {
let maxBodyLines: Int
let bodyFont: CGFloat
let titleBox: CGFloat
let bodyBox: CGFloat
init(periods: [WidgetPeriod], hourHeight: CGFloat) {
let slotMinutes = periods.first.map { $0.virtualEndMinutes - $0.virtualStartMinutes }
?? FALLBACK_SLOT_MINUTES
let slotHeight = CGFloat(slotMinutes) * hourHeight / 60
let inner = slotHeight - LESSON_GAP - 2 * TILE_PADDING_V
func font(lines: CGFloat) -> CGFloat {
inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines))
}
maxBodyLines = font(lines: 2) < STACKED_BODY_MIN_FONT ? 1 : 2
bodyFont = max(MIN_BODY_FONT, min(MAX_BODY_FONT, font(lines: CGFloat(maxBodyLines))))
titleBox = bodyFont * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT
bodyBox = bodyFont * LINE_BOX_PER_FONT
}
}
/// Distributes the tile height like the in-app _TileContent: the title comes
/// first, then as many body lines as fit.
struct TileTextLayout {
let title: String
let titleBox: CGFloat
let titleFont: CGFloat
let titleLines: Int
let body: [String]
/// Set when there is no room for a line below the title: the room moves
/// next to it instead.
let inline: String?
/// Index of the line (0 = title row) that has to stay clear of the "+N"
/// badge, or nil when the text ends above it.
let badgeLine: Int?
init(lesson: WidgetLesson, innerHeight: CGFloat, innerWidth: CGFloat, metrics: TileMetrics) {
title = !lesson.subjectShort.isEmpty ? lesson.subjectShort : (lesson.subjectLong ?? "—")
let candidates = [lesson.room, lesson.teacher ?? lesson.originalTeacher]
.compactMap { $0 }
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
let singleTitleBox = min(metrics.titleBox, innerHeight)
let capacity = max(
0,
min(metrics.maxBodyLines, Int((innerHeight - singleTitleBox) / metrics.bodyBox + 0.05))
)
if capacity >= candidates.count {
body = candidates
} else if capacity == 1 {
// Rather share one line than drop the teacher entirely.
body = [candidates.joined(separator: " · ")]
} else {
body = []
}
if capacity == 0, let first = candidates.first, !title.contains(first) {
inline = first
} else {
inline = nil
}
let spare = innerHeight - singleTitleBox - CGFloat(body.count) * metrics.bodyBox
if spare >= singleTitleBox,
let wrappedLine = TileTextLayout.wrappedTitleLineBox(
title, innerWidth: innerWidth, metrics: metrics
) {
titleBox = 2 * wrappedLine
titleFont = wrappedLine / LINE_BOX_PER_FONT
titleLines = 2
} else {
titleBox = singleTitleBox
titleFont = singleTitleBox / LINE_BOX_PER_FONT
titleLines = 1
}
let content = titleBox + CGFloat(body.count) * metrics.bodyBox
let hasBadge = (lesson.siblingCount ?? 0) > 0
badgeLine = hasBadge && innerHeight - content < BADGE_HEIGHT ? body.count : nil
}
/// Line box for a title wrapped onto two lines, or nil when it should
/// stay on one: it fits there at a readable size, or it has no word
/// boundary to break at. The box is capped so the longest word still
/// fits the tile width.
private static func wrappedTitleLineBox(
_ title: String,
innerWidth: CGFloat,
metrics: TileMetrics
) -> CGFloat? {
func widthPerFontPoint(_ text: String) -> CGFloat {
let font = UIFont.systemFont(ofSize: 100, weight: .medium)
return (text as NSString).size(withAttributes: [.font: font]).width / 100
}
if widthPerFontPoint(title) * TITLE_WRAP_BELOW_FONT <= innerWidth { return nil }
let words = title
.replacingOccurrences(of: "-", with: "- ")
.split(separator: " ")
.map(String.init)
guard words.count > 1, let widest = words.map(widthPerFontPoint).max(), widest > 0 else {
return nil
}
return min(metrics.titleBox, innerWidth / widest * LINE_BOX_PER_FONT)
}
}
extension Color {
/// Parses the `#RRGGBB` tile colour resolved in Dart.
init?(widgetHex hex: String?) {
guard let hex, hex.count == 7, hex.hasPrefix("#"),
let value = UInt32(hex.dropFirst(), radix: 16) else {
return nil
}
self.init(
red: Double((value >> 16) & 0xFF) / 255,
green: Double((value >> 8) & 0xFF) / 255,
blue: Double(value & 0xFF) / 255
)
}
}
extension WidgetLesson {
var tileColor: Color {
if let resolved = Color(widgetHex: color) { return resolved }
// Only reached for payloads written before Dart shipped the resolved
// tile colour; mirrors lesson_color.dart.
switch status {
// No time-based highlight: the widget timeline only ticks every
// ~30min, so "current" and "past" would be stale most of the day.
case .regular, .past, .ongoing:
return Color(red: 153 / 255, green: 51 / 255, blue: 51 / 255)
case .cancelled: return .black
case .irregular: return Color(red: 143 / 255, green: 25 / 255, blue: 179 / 255)
case .teacherChanged: return Color(red: 41 / 255, green: 99 / 255, blue: 155 / 255)
case .duty: return Color(red: 0, green: 121 / 255, blue: 107 / 255)
case .event:
return customColor != nil
? Color(red: 1, green: 160 / 255, blue: 0)
: Color(red: 46 / 255, green: 125 / 255, blue: 50 / 255)
}
}
}
@@ -20,36 +20,44 @@ struct TimetableWeekView: View {
@ViewBuilder @ViewBuilder
private func content(data: WidgetTimetableData) -> some View { private func content(data: WidgetTimetableData) -> some View {
let cal = Calendar.current
let days = (0..<5).map { offset in
cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
}
let weekLessons = data.lessons.filter { lesson in
days.contains { cal.isDate(lesson.start, inSameDayAs: $0) }
}
let periods = trimTrailingPeriods(data.periods, lessons: weekLessons)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
header(data: data) header(data: data)
dayHeaderRow(data: data) dayHeaderRow(days: days)
.padding(.top, GRID_MARGIN_TOP)
GeometryReader { geo in GeometryReader { geo in
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
let hourHeight = max(
MIN_HOUR_HEIGHT,
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
)
let dayColumnWidth = (geo.size.width - 28 - 4) / 5
// 45pt is the minimum where a 7pt-font room (e.g. "F2.04")
// plus the subject fits without `minimumScaleFactor`
// shrinking the subject into illegibility.
let subjectOnly = dayColumnWidth < 45
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
timeLabelsColumn(hourHeight: hourHeight, periods: data.periods) PeriodRuler(periods: periods, hourHeight: height)
.frame(width: 28, alignment: .topTrailing)
columnDivider columnDivider
ForEach(0..<5, id: \.self) { offset in ForEach(days.indices, id: \.self) { index in
column( TimeGridView(
data: data, lessons: weekLessons.filter {
offset: offset, cal.isDate($0.start, inSameDayAs: days[index])
hourHeight: hourHeight, },
subjectOnly: subjectOnly periods: periods,
hourHeight: height,
holiday: holiday(on: days[index], data: data),
isToday: cal.isDate(days[index], inSameDayAs: entry.date)
) )
.frame(maxWidth: .infinity) if index < 4 { columnDivider }
if offset < 4 { columnDivider }
} }
} }
} }
.padding(.top, 2)
}
}
private func holiday(on day: Date, data: WidgetTimetableData) -> WidgetDayInfo? {
data.days?.first {
$0.isHoliday && Calendar.current.isDate($0.date, inSameDayAs: day)
} }
} }
@@ -63,112 +71,55 @@ struct TimetableWeekView: View {
let cal = Calendar.current let cal = Calendar.current
let week = cal.component(.weekOfYear, from: data.anchorDate) let week = cal.component(.weekOfYear, from: data.anchorDate)
let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate
return HStack { return HStack(spacing: 6) {
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))") Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: 14, weight: .bold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.8) .minimumScaleFactor(0.65)
Spacer() .frame(maxWidth: .infinity, alignment: .leading)
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9)) .font(.system(size: 10))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.fixedSize()
} }
.frame(height: TITLE_ROW_HEIGHT)
} }
private func dayHeaderRow(data: WidgetTimetableData) -> some View { /// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
let cal = Calendar.current /// today inside an accent circle.
// `.frame(maxWidth: .infinity)` is critical: without an explicit private func dayHeaderRow(days: [Date]) -> some View {
// greedy width (or a true `Spacer()` inside), this HStack collapses
// to the sum of its fixed-width children in a `.leading` VStack
// and the 5 day-columns end up sharing ~5pt of width — the labels
// crunch to the left and stop aligning with the grid columns below.
// The fixed height keeps `columnDivider`'s vertically-flexible // The fixed height keeps `columnDivider`'s vertically-flexible
// Rectangle from stealing space from the GeometryReader. // Rectangle from stealing space from the GeometryReader below.
return HStack(spacing: 0) { HStack(spacing: 0) {
Spacer().frame(width: 28) Spacer().frame(width: RULER_WIDTH)
columnDivider columnDivider
ForEach(0..<5, id: \.self) { offset in ForEach(days.indices, id: \.self) { index in
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate dayHeaderCell(days[index])
VStack(spacing: -1) { .frame(maxWidth: .infinity)
Text(weekday(for: day)) if index < 4 { columnDivider }
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textPrimary)
Text(shortDate(day))
.font(.system(size: 8))
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity)
if offset < 4 { columnDivider }
} }
} }
// 20pt fits 10pt weekday + 8pt date with tight -1pt spacing. .frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT)
.frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20)
} }
private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View { private func dayHeaderCell(_ day: Date) -> some View {
let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES // `entry.date`, not `Date()` — WidgetKit archives entries ahead of
let totalHeight = CGFloat(totalMin) * hourHeight / 60.0 // time, the midnight entry has to mark its own day.
// Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date)
// time+name no longer fits in one period slot and the two labels return VStack(spacing: 1) {
// visually clip into the next slot. Drop to a single bold period Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased())
// number until there's room to read both lines. .font(.system(size: 9, weight: .medium))
let compact = hourHeight < 26 .foregroundStyle(isToday ? palette.accent : palette.textSecondary)
return ZStack(alignment: .topTrailing) { .lineLimit(1)
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in Text("\(Calendar.current.component(.day, from: day))")
Rectangle() .font(.system(size: 11, weight: isToday ? .bold : .regular))
.fill(palette.divider) .foregroundStyle(isToday ? palette.onAccent : palette.textPrimary)
.frame(height: 1) .lineLimit(1)
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0) .frame(width: 18, height: 18)
} .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,6 +33,9 @@ struct WidgetLesson: Codable {
let originalTeacher: String? let originalTeacher: String?
let status: WidgetLessonStatus let status: WidgetLessonStatus
let customColor: String? let customColor: String?
// Tile colour resolved in Dart as "#RRGGBB" (subject colours, palettes);
// nil only for payloads written before the field existed.
let color: String?
let siblingCount: Int? let siblingCount: Int?
} }
@@ -9,15 +9,27 @@ struct WidgetPalette {
let textPrimary: Color let textPrimary: Color
let textSecondary: Color let textSecondary: Color
let divider: Color let divider: Color
let accent: Color
let onAccent: Color
let breakBlock: Color let breakBlock: Color
let todayTint: Color
let holidayTint: Color
let watermarkOpacity: Double let watermarkOpacity: Double
// ColorScheme.fromSeed(#993333).primary of the app's light / dark theme.
private static let lightAccent = Color(red: 0x90 / 255, green: 0x4A / 255, blue: 0x47 / 255)
private static let darkAccent = Color(red: 0xFF / 255, green: 0xB3 / 255, blue: 0xAF / 255)
static let light = WidgetPalette( static let light = WidgetPalette(
background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255), background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255),
textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255), textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255),
textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255), textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255),
divider: Color.black.opacity(0x22 / 255.0), divider: Color.black.opacity(0x22 / 255.0),
breakBlock: Color.black.opacity(0x0C / 255.0), accent: lightAccent,
onAccent: .white,
breakBlock: lightAccent.opacity(0x32 / 255.0),
todayTint: lightAccent.opacity(0x0E / 255.0),
holidayTint: Color.black.opacity(0x32 / 255.0),
watermarkOpacity: 0.014 watermarkOpacity: 0.014
) )
@@ -26,7 +38,11 @@ struct WidgetPalette {
textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255), textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255),
textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255), textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255),
divider: Color.white.opacity(0x33 / 255.0), divider: Color.white.opacity(0x33 / 255.0),
breakBlock: Color.white.opacity(0x14 / 255.0), accent: darkAccent,
onAccent: Color(red: 0x57 / 255, green: 0x1D / 255, blue: 0x1D / 255),
breakBlock: darkAccent.opacity(0x32 / 255.0),
todayTint: darkAccent.opacity(0x0E / 255.0),
holidayTint: Color.white.opacity(0x32 / 255.0),
watermarkOpacity: 0.025 watermarkOpacity: 0.025
) )
+2
View File
@@ -0,0 +1,2 @@
app_identifier("eu.mhsl.marianum.mobile.client")
team_id("MY55VF3KPG")
+27
View File
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,49 @@
# 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.
+5
View File
@@ -7,4 +7,9 @@ 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 [];
} }
+77 -28
View File
@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../session/session_manager.dart';
import '../utils/directory_size.dart'; import '../utils/directory_size.dart';
/// One cached response: the raw JSON payload and when it was stored. /// One cached response: the raw JSON payload and when it was stored.
@@ -22,6 +23,9 @@ class CacheEntry {
/// in memory for the whole session and JSON-encodes payloads a second time. /// in memory for the whole session and JSON-encodes payloads a second time.
/// Here every access is async and touches exactly one file, and the payload is /// Here every access is async and touches exactly one file, and the payload is
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`). /// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
///
/// Every account has its own directory; all access goes to the one of the
/// active account, in every isolate.
class CacheStore { class CacheStore {
CacheStore._(); CacheStore._();
@@ -30,15 +34,36 @@ class CacheStore {
/// Payloads above this size are decoded on a background isolate. /// Payloads above this size are decoded on a background isolate.
static const int isolateDecodeThreshold = 64 * 1024; static const int isolateDecodeThreshold = 64 * 1024;
Future<Directory>? _dir; static const _directoryPrefix = 'request_cache';
final Map<String, Future<Directory>> _dirs = {};
final Map<String, Future<void>> _writes = {}; final Map<String, Future<void>> _writes = {};
Future<Directory> _directory() => _dir ??= () async { /// Directory name for an account's storage [namespace]; null while no
final base = await getApplicationCacheDirectory(); /// account is active. The account from before multi-account support
final dir = Directory('${base.path}/request_cache'); /// (namespace '') keeps the original directory.
await dir.create(recursive: true); @visibleForTesting
return dir; static String directoryName(String? namespace) => switch (namespace) {
}(); null => '$_directoryPrefix-signed-out',
'' => _directoryPrefix,
_ => '$_directoryPrefix-$namespace',
};
Future<Directory> _directory() {
final name = directoryName(SessionManager().activeAccount?.namespace);
return _dirs[name] ??= () async {
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 => Future<File> _file(String key) async =>
File('${(await _directory()).path}/${_safeName(key)}'); File('${(await _directory()).path}/${_safeName(key)}');
@@ -49,8 +74,9 @@ class CacheStore {
Future<CacheEntry?> read(String key) async { Future<CacheEntry?> read(String key) async {
try { try {
await _writes[key]; final file = await _file(key);
return parse(await (await _file(key)).readAsString()); await _writes[file.path];
return parse(await file.readAsString());
} on Object { } on Object {
// Missing (PathNotFoundException) or unreadable: a cache miss. // Missing (PathNotFoundException) or unreadable: a cache miss.
return null; return null;
@@ -64,18 +90,21 @@ class CacheStore {
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]}; return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
} }
Future<void> write(String key, String json) { Future<void> write(String key, String json) async {
final previous = _writes[key] ?? Future<void>.value(); // Resolved up front: a queued write must not follow an account switch
// into the next account's directory.
final file = await _file(key);
final path = file.path;
final previous = _writes[path] ?? Future<void>.value();
late final Future<void> current; late final Future<void> current;
current = previous.then((_) => _write(key, json)).whenComplete(() { current = previous.then((_) => _write(file, json)).whenComplete(() {
if (identical(_writes[key], current)) _writes.remove(key); if (identical(_writes[path], current)) _writes.remove(path);
}); });
return _writes[key] = current; return _writes[path] = current;
} }
Future<void> _write(String key, String json) async { Future<void> _write(File file, String json) async {
try { try {
final file = await _file(key);
// Write-then-rename so a reader (or the widget background isolate) // Write-then-rename so a reader (or the widget background isolate)
// never sees a half-written file. // never sees a half-written file.
final tmp = File('${file.path}.tmp'); final tmp = File('${file.path}.tmp');
@@ -85,19 +114,21 @@ class CacheStore {
); );
await tmp.rename(file.path); await tmp.rename(file.path);
} on Object catch (e) { } on Object catch (e) {
debugPrint('CacheStore.write($key) failed: $e'); debugPrint('CacheStore.write(${file.path}) failed: $e');
} }
} }
Future<void> delete(String key) async { Future<void> delete(String key) async {
try { try {
await _writes[key]; final file = await _file(key);
await (await _file(key)).delete(); await _writes[file.path];
await file.delete();
} on Object { } on Object {
// A missing or locked file is as good as deleted for a cache. // A missing or locked file is as good as deleted for a cache.
} }
} }
/// Empties the active account's cache.
Future<void> clear() async { Future<void> clear() async {
try { try {
final dir = await _directory(); final dir = await _directory();
@@ -105,7 +136,20 @@ class CacheStore {
} on Object catch (e) { } on Object catch (e) {
debugPrint('CacheStore.clear failed: $e'); debugPrint('CacheStore.clear failed: $e');
} }
_dir = null; _dirs.clear();
}
/// Removes the cache of the account with [namespace], active or not.
Future<void> deleteNamespace(String namespace) async {
final name = directoryName(namespace);
try {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.deleteNamespace failed: $e');
}
_dirs.removeWhere((key, _) => key == name);
} }
/// All stored keys, optionally filtered by [prefix]. /// All stored keys, optionally filtered by [prefix].
@@ -124,17 +168,22 @@ class CacheStore {
} }
} }
/// Removes entries not written for [maxAge], judged by file mtime so nothing /// Removes entries not written for [maxAge] from every account's cache,
/// has to be read or decoded. /// judged by file mtime so nothing has to be read or decoded.
Future<void> deleteOlderThan(Duration maxAge) async { Future<void> deleteOlderThan(Duration maxAge) async {
try { try {
final dir = await _directory(); final base = await getApplicationCacheDirectory();
final cutoff = DateTime.now().subtract(maxAge); final cutoff = DateTime.now().subtract(maxAge);
await for (final entity in dir.list()) { await for (final dir in base.list()) {
if (entity is! File) continue; if (dir is! Directory) continue;
// ignore: avoid_slow_async_io final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
final modified = (await entity.stat()).modified; if (!name.startsWith(_directoryPrefix)) continue;
if (modified.isBefore(cutoff)) await entity.delete(); await for (final entity in dir.list()) {
if (entity is! File) continue;
// ignore: avoid_slow_async_io
final modified = (await entity.stat()).modified;
if (modified.isBefore(cutoff)) await entity.delete();
}
} }
} on Object catch (e) { } on Object catch (e) {
debugPrint('CacheStore.deleteOlderThan failed: $e'); debugPrint('CacheStore.deleteOlderThan failed: $e');
+4 -3
View File
@@ -1,4 +1,4 @@
import '../../../model/account_data.dart'; import '../../../session/session_manager.dart';
import '../../marianumcloud/talk/chat/get_chat_response.dart'; import '../../marianumcloud/talk/chat/get_chat_response.dart';
import '../../marianumcloud/talk/room/get_room_response.dart'; import '../../marianumcloud/talk/room/get_room_response.dart';
import '../demo_persona.dart'; import '../demo_persona.dart';
@@ -204,7 +204,8 @@ class DemoTalk {
id: base + 2, id: base + 2,
token: token, token: token,
ago: const Duration(days: 1, hours: 6), ago: const Duration(days: 1, hours: 6),
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?', message:
'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
), ),
_msg( _msg(
id: base + 3, id: base + 3,
@@ -366,7 +367,7 @@ class DemoTalk {
}) => _msg( }) => _msg(
id: id, id: id,
token: token, token: token,
actor: AccountData().getUsername(), actor: SessionManager().requireNextcloud().username,
display: DemoPersona.studentName, display: DemoPersona.studentName,
ago: ago, ago: ago,
message: message, message: message,
+28
View File
@@ -0,0 +1,28 @@
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,6 +32,8 @@ 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':
+3 -3
View File
@@ -1,4 +1,4 @@
import '../../model/account_data.dart'; import '../../session/session_manager.dart';
/// Central switch for the client-side demo mode. /// Central switch for the client-side demo mode.
/// ///
@@ -8,7 +8,7 @@ import '../../model/account_data.dart';
/// Play reviewers and automated screenshot runs see a fully populated app /// Play reviewers and automated screenshot runs see a fully populated app
/// without a real account and without any network dependency. /// without a real account and without any network dependency.
/// ///
/// The flag is persisted through [AccountData.isDemo], so a demo session /// The flag is persisted through [Session.isDemo], so a demo session
/// survives cold starts exactly like a normal login. It works in release builds /// survives cold starts exactly like a normal login. It works in release builds
/// too — reviewers run the shipped release build. /// too — reviewers run the shipped release build.
class DemoMode { class DemoMode {
@@ -23,5 +23,5 @@ class DemoMode {
username.trim().toLowerCase().startsWith(usernamePrefix); username.trim().toLowerCase().startsWith(usernamePrefix);
/// True while the active session is a demo session. /// True while the active session is a demo session.
static bool get active => AccountData().isDemo; static bool get active => SessionManager().isDemo;
} }
+67 -20
View File
@@ -5,13 +5,12 @@ import 'package:dio/dio.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import '../../session/session.dart';
import '../api_error.dart'; import '../api_error.dart';
import '../http_errors.dart'; import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart'; import '../marianumcloud/talk/talk_error.dart';
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';
@@ -55,30 +54,74 @@ 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 preview = previewBody(error.body); final dav = parseDavError(error.body);
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview'; if (dav != null) {
switch (status) { final detail = 'HTTP $status ${dav.exception}: ${dav.message}';
case 401: return _davToAppException(status, dav, detail) ??
return AuthException.unauthorized(technicalDetails: detail); exceptionForStatus(status, detail);
case 403:
return AuthException.forbidden(technicalDetails: detail);
case 404:
return NotFoundException(technicalDetails: detail);
case 429:
return ServerException(
statusCode: status,
userMessage:
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
technicalDetails: detail,
);
default:
return ServerException(statusCode: status, technicalDetails: detail);
} }
final preview = previewBody(error.body);
final detail = preview.isEmpty
? 'HTTP $status'
: 'HTTP $status body=$preview';
if (status == 429) {
return ServerException(
statusCode: status,
userMessage:
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
technicalDetails: detail,
);
}
return exceptionForStatus(status, detail);
} }
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
@@ -86,6 +129,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error is AppException) return error.userMessage; if (error is AppException) return error.userMessage;
if (error is TalkError) return TalkException(error).userMessage; if (error is TalkError) return TalkException(error).userMessage;
if (error is NextcloudUnavailableException) {
return 'Diese Funktion ist mit deinem Konto nicht verfügbar.';
}
if (error is DioException) { if (error is DioException) {
final mapped = _dioToAppException(error); final mapped = _dioToAppException(error);
@@ -136,6 +182,7 @@ String? errorToTechnicalDetails(Object? error) {
bool errorAllowsRetry(Object? error) { bool errorAllowsRetry(Object? error) {
if (error == null) return true; if (error == null) return true;
if (error is AppException) return error.allowRetry; if (error is AppException) return error.allowRetry;
if (error is NextcloudUnavailableException) return false;
if (error is DioException) { if (error is DioException) {
final mapped = _dioToAppException(error); final mapped = _dioToAppException(error);
if (mapped != null) return mapped.allowRetry; if (mapped != null) return mapped.allowRetry;
+6
View File
@@ -13,4 +13,10 @@ 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,6 +7,7 @@ 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".
@@ -14,6 +15,5 @@ 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,
); );
} }
+20 -8
View File
@@ -3,6 +3,7 @@ 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';
@@ -42,12 +43,23 @@ String httpErrorDetail(String label, String body, int status) {
: '$label -> HTTP $status body=$preview'; : '$label -> HTTP $status body=$preview';
} }
/// Throws the [AppException] matching a non-2xx HTTP [status], carrying /// The [AppException] matching a non-2xx HTTP [status], carrying [detail] as
/// [detail] as technical details: 401/403 map to auth errors, 404 to /// technical details: 401/403 map to auth errors, 404 to not-found, everything
/// not-found, everything else to a generic server error. /// else to a generic server error.
Never throwForStatus(int status, String detail) { AppException exceptionForStatus(int status, String detail) => switch (status) {
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail); 401 => AuthException.unauthorized(technicalDetails: detail),
if (status == 403) throw AuthException.forbidden(technicalDetails: detail); 403 => AuthException.forbidden(technicalDetails: detail),
if (status == 404) throw NotFoundException(technicalDetails: detail); 404 => NotFoundException(technicalDetails: detail),
throw ServerException(statusCode: status, technicalDetails: detail); _ => 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
@@ -0,0 +1,46 @@
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;
}
@@ -16,10 +16,9 @@ class DeleteAppPassword {
Future<void> run({String? authorizationHeader}) async { Future<void> run({String? authorizationHeader}) async {
await _client.delete( await _client.delete(
NextcloudOcs.uri('core/apppassword'), NextcloudOcs.uri('core/apppassword'),
headers: { // An explicit header may belong to an inactive account, so the active
...NextcloudOcs.headers(), // session's headers must not be required then.
'Authorization': ?authorizationHeader, headers: NextcloudOcs.headers(authorization: authorizationHeader),
},
); );
} }
} }
@@ -2,13 +2,13 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../model/account_data.dart'; import '../../../session/session_manager.dart';
import '../../http_errors.dart'; import '../../http_errors.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
/// Exchanges the user's real Nextcloud password for a scoped app password via /// Exchanges the user's real Nextcloud password for a scoped app password via
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then /// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]), /// authenticate with the app password (see [NextcloudCredentials.basicAuthHeader]),
/// which is what the push-v2 registration binds to. /// which is what the push-v2 registration binds to.
/// ///
/// Must authenticate with the *real* password — an app password cannot mint /// Must authenticate with the *real* password — an app password cannot mint
@@ -28,21 +28,17 @@ class GetAppPassword {
label, label,
() => _client.get( () => _client.get(
NextcloudOcs.uri('core/getapppassword'), NextcloudOcs.uri('core/getapppassword'),
headers: { // Deliberately NOT the shared Authorization value: that one prefers
...NextcloudOcs.headers(), // the app password, but an app password cannot mint another one —
// Deliberately NOT the shared Authorization value: that one prefers // this endpoint requires the real password.
// the app password, but an app password cannot mint another one — headers: NextcloudOcs.headers(
// this endpoint requires the real password. authorization: SessionManager()
'Authorization': AccountData().getRealPasswordBasicAuthHeader(), .requireNextcloud()
}, .realPasswordBasicAuthHeader,
),
), ),
))!; ))!;
if (response.statusCode < 200 || response.statusCode >= 300) { ensureSuccess(label, response);
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;
@@ -5,6 +5,8 @@ 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 '../../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';
@@ -43,6 +45,13 @@ class AutocompleteApi {
technicalDetails: 'core/autocomplete/get: ${response.body}', technicalDetails: 'core/autocomplete/get: ${response.body}',
); );
} }
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body)); final result = AutocompleteResponse.fromJson(
NextcloudOcs.decode(response.body),
);
ClientErrorReporter.reportDecodeWarnings(
'Autocomplete',
drainDecodeWarnings(),
);
return result;
} }
} }
@@ -1,5 +1,6 @@
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';
@@ -14,6 +15,7 @@ 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);
@@ -23,15 +25,31 @@ 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? shareWithDisplayNameUniqe; String? shareWithDisplayNameUnique;
AutocompleteResponseObject( AutocompleteResponseObject(
this.id, this.id,
@@ -40,7 +58,7 @@ class AutocompleteResponseObject {
this.source, this.source,
this.status, this.status,
this.subline, this.subline,
this.shareWithDisplayNameUniqe, this.shareWithDisplayNameUnique,
); );
factory AutocompleteResponseObject.fromJson(Map<String, dynamic> json) => factory AutocompleteResponseObject.fromJson(Map<String, dynamic> json) =>
@@ -8,13 +8,12 @@ part of 'autocomplete_response.dart';
AutocompleteResponse _$AutocompleteResponseFromJson( AutocompleteResponse _$AutocompleteResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => AutocompleteResponse( ) => $checkedCreate('AutocompleteResponse', json, ($checkedConvert) {
(json['data'] as List<dynamic>) final val = AutocompleteResponse(
.map( $checkedConvert('data', (v) => _entriesFromJson(v)),
(e) => AutocompleteResponseObject.fromJson(e as Map<String, dynamic>), );
) return val;
.toList(), });
);
Map<String, dynamic> _$AutocompleteResponseToJson( Map<String, dynamic> _$AutocompleteResponseToJson(
AutocompleteResponse instance, AutocompleteResponse instance,
@@ -22,15 +21,18 @@ Map<String, dynamic> _$AutocompleteResponseToJson(
AutocompleteResponseObject _$AutocompleteResponseObjectFromJson( AutocompleteResponseObject _$AutocompleteResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => AutocompleteResponseObject( ) => $checkedCreate('AutocompleteResponseObject', json, ($checkedConvert) {
json['id'] as String, final val = AutocompleteResponseObject(
json['label'] as String, $checkedConvert('id', (v) => v as String),
json['icon'] as String?, $checkedConvert('label', (v) => v as String),
json['source'] as String?, $checkedConvert('icon', (v) => v as String?),
json['status'] as String?, $checkedConvert('source', (v) => v as String?),
json['subline'] as String?, $checkedConvert('status', (v) => _statusFromJson(v)),
json['shareWithDisplayNameUniqe'] as String?, $checkedConvert('subline', (v) => v as String?),
); $checkedConvert('shareWithDisplayNameUnique', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$AutocompleteResponseObjectToJson( Map<String, dynamic> _$AutocompleteResponseObjectToJson(
AutocompleteResponseObject instance, AutocompleteResponseObject instance,
@@ -41,5 +43,5 @@ Map<String, dynamic> _$AutocompleteResponseObjectToJson(
'source': instance.source, 'source': instance.source,
'status': instance.status, 'status': instance.status,
'subline': instance.subline, 'subline': instance.subline,
'shareWithDisplayNameUniqe': instance.shareWithDisplayNameUniqe, 'shareWithDisplayNameUnique': instance.shareWithDisplayNameUnique,
}; };
@@ -1,11 +1,10 @@
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../../session/session_manager.dart';
import '../../errors/parse_exception.dart'; import '../../errors/parse_exception.dart';
import '../../http_errors.dart'; import '../../http_errors.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
@@ -27,12 +26,12 @@ Uri _coreAvatarUri() {
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/'); return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
} }
Uri _userInfoUri() => Uri _userInfoUri() => NextcloudOcs.uri(
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}'); 'cloud/users/${SessionManager().requireNextcloud().username}',
);
Future<http.Response> _send( Future<http.Response> _send(
Future<http.Response> Function(Uri uri, Map<String, String> headers) Future<http.Response> Function(Uri uri, Map<String, String> headers) perform,
perform,
Uri uri, Uri uri,
) async { ) async {
final headers = NextcloudOcs.headers(); final headers = NextcloudOcs.headers();
@@ -96,18 +95,14 @@ class GetUserInfo {
uri, uri,
); );
try { try {
final root = jsonDecode(response.body) as Map<String, dynamic>;
final data = final data =
(root['ocs'] as Map<String, dynamic>)['data'] NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
as Map<String, dynamic>;
return CloudUserInfo( return CloudUserInfo(
userId: data['id'] as String, userId: data['id'] as String,
displayName: (data['displayname'] as String?) ?? '', displayName: (data['displayname'] as String?) ?? '',
); );
} catch (e) { } catch (e) {
throw ParseException( throw ParseException(technicalDetails: 'Cloud $uri user info parse: $e');
technicalDetails: 'Cloud $uri user info parse: $e',
);
} }
} }
} }
@@ -8,16 +8,19 @@ part of 'file_sharing_api_params.dart';
FileSharingApiParams _$FileSharingApiParamsFromJson( FileSharingApiParams _$FileSharingApiParamsFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => FileSharingApiParams( ) => $checkedCreate('FileSharingApiParams', json, ($checkedConvert) {
shareType: (json['shareType'] as num).toInt(), final val = FileSharingApiParams(
shareWith: json['shareWith'] as String, shareType: $checkedConvert('shareType', (v) => (v as num).toInt()),
path: json['path'] as String, shareWith: $checkedConvert('shareWith', (v) => v as String),
referenceId: json['referenceId'] as String?, path: $checkedConvert('path', (v) => v as String),
talkMetaData: json['talkMetaData'] as String?, referenceId: $checkedConvert('referenceId', (v) => v as String?),
permissions: (json['permissions'] as num?)?.toInt(), talkMetaData: $checkedConvert('talkMetaData', (v) => v as String?),
password: json['password'] as String?, permissions: $checkedConvert('permissions', (v) => (v as num?)?.toInt()),
expireDate: json['expireDate'] as String?, password: $checkedConvert('password', (v) => v as String?),
); expireDate: $checkedConvert('expireDate', (v) => v as String?),
);
return val;
});
Map<String, dynamic> _$FileSharingApiParamsToJson( Map<String, dynamic> _$FileSharingApiParamsToJson(
FileSharingApiParams instance, FileSharingApiParams instance,
@@ -29,12 +29,7 @@ class LoginFlowApi {
headers: {'Accept': 'application/json', 'User-Agent': userAgent}, headers: {'Accept': 'application/json', 'User-Agent': userAgent},
), ),
))!; ))!;
if (response.statusCode < 200 || response.statusCode >= 300) { ensureSuccess(label, response);
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>,
); );
@@ -53,12 +48,7 @@ class LoginFlowApi {
), ),
))!; ))!;
if (response.statusCode == 404) return null; if (response.statusCode == 404) return null;
if (response.statusCode < 200 || response.statusCode >= 300) { ensureSuccess(label, response);
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>,
); );
+6 -3
View File
@@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import '../../model/account_data.dart';
import '../../model/endpoint_data.dart'; import '../../model/endpoint_data.dart';
import '../../session/session_manager.dart';
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by /// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
/// TalkApi, AutocompleteApi, FileSharingApi. /// TalkApi, AutocompleteApi, FileSharingApi.
@@ -13,10 +13,13 @@ 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>;
static Map<String, String> headers() => { /// [authorization] overrides the active session's credentials, which are
/// then not required.
static Map<String, String> headers({String? authorization}) => {
'Accept': 'application/json', 'Accept': 'application/json',
'OCS-APIRequest': 'true', 'OCS-APIRequest': 'true',
'Authorization': AccountData().getBasicAuthHeader(), 'Authorization':
authorization ?? SessionManager().requireNextcloud().basicAuthHeader,
}; };
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) { static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
@@ -0,0 +1,37 @@
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);
}
}
@@ -0,0 +1,94 @@
/// 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,14 +7,20 @@ part of 'search_files_response.dart';
// ************************************************************************** // **************************************************************************
SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) => SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) =>
SearchFilesResponse( $checkedCreate('SearchFilesResponse', json, ($checkedConvert) {
name: json['name'] as String, final val = SearchFilesResponse(
isPaginated: json['isPaginated'] as bool, name: $checkedConvert('name', (v) => v as String),
cursor: (json['cursor'] as num?)?.toInt(), isPaginated: $checkedConvert('isPaginated', (v) => v as bool),
entries: (json['entries'] as List<dynamic>) cursor: $checkedConvert('cursor', (v) => (v as num?)?.toInt()),
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>)) entries: $checkedConvert(
.toList(), 'entries',
); (v) => (v as List<dynamic>)
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>))
.toList(),
),
);
return val;
});
Map<String, dynamic> _$SearchFilesResponseToJson( Map<String, dynamic> _$SearchFilesResponseToJson(
SearchFilesResponse instance, SearchFilesResponse instance,
@@ -26,13 +32,19 @@ Map<String, dynamic> _$SearchFilesResponseToJson(
}; };
SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) => SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) =>
SearchFilesEntry( $checkedCreate('SearchFilesEntry', json, ($checkedConvert) {
title: json['title'] as String, final val = SearchFilesEntry(
subline: json['subline'] as String?, title: $checkedConvert('title', (v) => v as String),
icon: json['icon'] as String?, subline: $checkedConvert('subline', (v) => v as String?),
resourceUrl: json['resourceUrl'] as String?, icon: $checkedConvert('icon', (v) => v as String?),
attributes: json['attributes'] as Map<String, dynamic>?, resourceUrl: $checkedConvert('resourceUrl', (v) => v as String?),
); 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>{
@@ -0,0 +1,46 @@
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,6 +1,3 @@
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';
@@ -16,11 +13,4 @@ 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);
} }
@@ -1,17 +1,12 @@
import 'package:flutter/foundation.dart'; 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. Bypasses [TalkApi] /// that fetches the page of messages *older* than a given id. The server
/// because that layer treats non-2xx as errors, and the server answers HTTP 304 /// answers HTTP 304 when there are no older messages left — a normal "start of
/// when there are no older messages left — a normal "start of chat" outcome here. /// chat" outcome (see [fetchChatPage]). `setReadMarker=off` so paging into
/// `setReadMarker=off` so paging into history never moves the read cursor. /// history never moves the read cursor.
class GetChatHistory { class GetChatHistory {
final String chatToken; final String chatToken;
final int lastKnownMessageId; final int lastKnownMessageId;
@@ -24,39 +19,18 @@ 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() async { Future<GetChatResponse?> run() => fetchChatPage(
final params = GetChatParams( label: 'GetChatHistory',
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,
); ),
final uri = NextcloudOcs.uri( // A page holds up to 200 messages and lands while the user is scrolling;
'apps/spreed/api/v1/chat/$chatToken', // decoding it on the UI isolate stalls the fling.
queryParameters: params.toJson(), parseOffIsolate: true,
); );
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) {
// A page holds up to 200 messages and lands while the user is scrolling;
// decoding it on the UI isolate stalls the fling.
final parsed = await compute(_parseChatResponse, response.body);
return parsed..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'GetChatHistory $uri: HTTP $status',
);
}
} }
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
@@ -7,24 +7,33 @@ part of 'get_chat_params.dart';
// ************************************************************************** // **************************************************************************
GetChatParams _$GetChatParamsFromJson(Map<String, dynamic> json) => GetChatParams _$GetChatParamsFromJson(Map<String, dynamic> json) =>
GetChatParams( $checkedCreate('GetChatParams', json, ($checkedConvert) {
lookIntoFuture: $enumDecode( final val = GetChatParams(
_$GetChatParamsSwitchEnumMap, lookIntoFuture: $checkedConvert(
json['lookIntoFuture'], 'lookIntoFuture',
), (v) => $enumDecode(_$GetChatParamsSwitchEnumMap, v),
limit: (json['limit'] as num?)?.toInt(), ),
lastKnownMessageId: (json['lastKnownMessageId'] as num?)?.toInt(), limit: $checkedConvert('limit', (v) => (v as num?)?.toInt()),
lastCommonReadId: (json['lastCommonReadId'] as num?)?.toInt(), lastKnownMessageId: $checkedConvert(
timeout: (json['timeout'] as num?)?.toInt(), 'lastKnownMessageId',
setReadMarker: $enumDecodeNullable( (v) => (v as num?)?.toInt(),
_$GetChatParamsSwitchEnumMap, ),
json['setReadMarker'], lastCommonReadId: $checkedConvert(
), 'lastCommonReadId',
includeLastKnown: $enumDecodeNullable( (v) => (v as num?)?.toInt(),
_$GetChatParamsSwitchEnumMap, ),
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,12 +2,14 @@ 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);
@@ -23,15 +25,20 @@ 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;
@@ -40,6 +47,11 @@ 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
@@ -70,10 +82,33 @@ class GetChatResponseObject {
_$GetChatResponseObjectFromJson(json); _$GetChatResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetChatResponseObjectToJson(this); Map<String, dynamic> toJson() => _$GetChatResponseObjectToJson(this);
static GetChatResponseObject getDateDummy(int timestamp) { /// [timestamp] is in seconds.
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); DateTime get sentAt => 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(
@@ -95,14 +130,24 @@ 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 element in json.keys) { for (final entry in json.entries) {
data.putIfAbsent( final value = entry.value;
element, if (value is Map<String, dynamic>) {
() => RichObjectString.fromJson(json[element] as Map<String, dynamic>), data[entry.key] = RichObjectString.fromJson(value);
); }
} }
return data; return data;
} }
@@ -112,6 +157,7 @@ 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;
@@ -119,7 +165,17 @@ class RichObjectString {
String? path; String? path;
String? link; String? link;
RichObjectString(this.type, this.id, this.name, this.path, this.link); /// Only set for `file` objects.
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);
@@ -143,4 +199,14 @@ 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,16 +7,18 @@ part of 'get_chat_response.dart';
// ************************************************************************** // **************************************************************************
GetChatResponse _$GetChatResponseFromJson(Map<String, dynamic> json) => GetChatResponse _$GetChatResponseFromJson(Map<String, dynamic> json) =>
GetChatResponse( $checkedCreate('GetChatResponse', json, ($checkedConvert) {
(json['data'] as List<dynamic>) final val = GetChatResponse(
.map( $checkedConvert('data', (v) => _messagesFromJson(v)),
(e) => GetChatResponseObject.fromJson(e as Map<String, dynamic>),
)
.toSet(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
); );
$checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(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>{
@@ -26,31 +28,49 @@ Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) =>
GetChatResponseObject _$GetChatResponseObjectFromJson( GetChatResponseObject _$GetChatResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => GetChatResponseObject( ) => $checkedCreate('GetChatResponseObject', json, ($checkedConvert) {
(json['id'] as num).toInt(), final val = GetChatResponseObject(
json['token'] as String, $checkedConvert('id', (v) => (v as num).toInt()),
$enumDecode( $checkedConvert('token', (v) => v as String),
_$GetRoomResponseObjectMessageActorTypeEnumMap, $checkedConvert(
json['actorType'], 'actorType',
), (v) => $enumDecode(
json['actorId'] as String, _$GetRoomResponseObjectMessageActorTypeEnumMap,
json['actorDisplayName'] as String, v,
(json['timestamp'] as num).toInt(), unknownValue: GetRoomResponseObjectMessageActorType.unknown,
json['systemMessage'] as String, ),
$enumDecode(_$GetRoomResponseObjectMessageTypeEnumMap, json['messageType']), ),
json['isReplyable'] as bool, $checkedConvert('actorId', (v) => v as String),
json['referenceId'] as String, $checkedConvert('actorDisplayName', (v) => v as String),
json['message'] as String, $checkedConvert('timestamp', (v) => (v as num).toInt()),
_fromJson(json['messageParameters']), $checkedConvert('systemMessage', (v) => v as String),
(json['reactions'] as Map<String, dynamic>?)?.map( $checkedConvert(
(k, e) => MapEntry(k, (e as num).toInt()), 'messageType',
), (v) => $enumDecode(
(json['reactionsSelf'] as List<dynamic>?)?.map((e) => e as String).toList(), _$GetRoomResponseObjectMessageTypeEnumMap,
json['parent'] == null v,
? null unknownValue: GetRoomResponseObjectMessageType.unknown,
: GetChatResponseObject.fromJson(json['parent'] as Map<String, dynamic>), ),
json['markdown'] as bool? ?? false, ),
); $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()),
),
),
$checkedConvert(
'reactionsSelf',
(v) => (v as List<dynamic>?)?.map((e) => e as String).toList(),
),
$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,
@@ -83,6 +103,12 @@ 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 = {
@@ -91,16 +117,28 @@ 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) =>
RichObjectString( $checkedCreate('RichObjectString', json, ($checkedConvert) {
$enumDecode(_$RichObjectStringObjectTypeEnumMap, json['type']), final val = RichObjectString(
json['id'] as String, $checkedConvert(
json['name'] as String, 'type',
json['path'] as String?, (v) => $enumDecode(
json['link'] as String?, _$RichObjectStringObjectTypeEnumMap,
); 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>{
@@ -109,6 +147,7 @@ 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 = {
@@ -120,4 +159,8 @@ 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,15 +1,10 @@
import 'package:http/http.dart' as http; import 'fetch_chat_page.dart';
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`). Bypasses [TalkApi] /// Long-poll variant of GetChat (`lookIntoFuture=1`); HTTP 304 is the normal
/// because that layer treats non-2xx as errors, and we need 304 to be a /// "no new messages" outcome (see [fetchChatPage]). `setReadMarker=on` lets
/// normal "no new messages" outcome. `setReadMarker=on` lets the server /// the server move the read cursor whenever the call returns messages.
/// 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;
@@ -22,37 +17,17 @@ 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() async { Future<GetChatResponse?> run() => fetchChatPage(
final params = GetChatParams( label: 'LongPollChat',
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,
); ),
final uri = NextcloudOcs.uri( timeout: Duration(seconds: timeoutSeconds + 15),
'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,14 +7,18 @@ part of 'create_poll_params.dart';
// ************************************************************************** // **************************************************************************
CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) => CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) =>
CreatePollParams( $checkedCreate('CreatePollParams', json, ($checkedConvert) {
question: json['question'] as String, final val = CreatePollParams(
options: (json['options'] as List<dynamic>) question: $checkedConvert('question', (v) => v as String),
.map((e) => e as String) options: $checkedConvert(
.toList(), 'options',
resultMode: (json['resultMode'] as num).toInt(), (v) => (v as List<dynamic>).map((e) => e as String).toList(),
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,14 +7,17 @@ part of 'create_room_params.dart';
// ************************************************************************** // **************************************************************************
CreateRoomParams _$CreateRoomParamsFromJson(Map<String, dynamic> json) => CreateRoomParams _$CreateRoomParamsFromJson(Map<String, dynamic> json) =>
CreateRoomParams( $checkedCreate('CreateRoomParams', json, ($checkedConvert) {
roomType: (json['roomType'] as num).toInt(), final val = CreateRoomParams(
invite: json['invite'] as String, roomType: $checkedConvert('roomType', (v) => (v as num).toInt()),
source: json['source'] as String?, invite: $checkedConvert('invite', (v) => v as String),
roomName: json['roomName'] as String?, source: $checkedConvert('source', (v) => v as String?),
objectType: json['objectType'] as String?, roomName: $checkedConvert('roomName', (v) => v as String?),
objectId: json['objectId'] as String?, objectType: $checkedConvert('objectType', (v) => v 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,12 +7,21 @@ part of 'create_room_response.dart';
// ************************************************************************** // **************************************************************************
CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) => CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) =>
CreateRoomResponse( $checkedCreate('CreateRoomResponse', json, ($checkedConvert) {
CreateRoomResponseData.fromJson(json['data'] as Map<String, dynamic>), final val = CreateRoomResponse(
) $checkedConvert(
..headers = (json['headers'] as Map<String, dynamic>?)?.map( 'data',
(k, e) => MapEntry(k, e as String), (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),
),
);
return val;
});
Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) => Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -22,7 +31,12 @@ Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
CreateRoomResponseData _$CreateRoomResponseDataFromJson( CreateRoomResponseData _$CreateRoomResponseDataFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => CreateRoomResponseData(json['token'] as String); ) => $checkedCreate('CreateRoomResponseData', json, ($checkedConvert) {
final val = CreateRoomResponseData(
$checkedConvert('token', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$CreateRoomResponseDataToJson( Map<String, dynamic> _$CreateRoomResponseDataToJson(
CreateRoomResponseData instance, CreateRoomResponseData instance,
@@ -8,7 +8,12 @@ part of 'delete_react_message_params.dart';
DeleteReactMessageParams _$DeleteReactMessageParamsFromJson( DeleteReactMessageParams _$DeleteReactMessageParamsFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => DeleteReactMessageParams(json['reaction'] as String); ) => $checkedCreate('DeleteReactMessageParams', json, ($checkedConvert) {
final val = DeleteReactMessageParams(
$checkedConvert('reaction', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$DeleteReactMessageParamsToJson( Map<String, dynamic> _$DeleteReactMessageParamsToJson(
DeleteReactMessageParams instance, DeleteReactMessageParams instance,
@@ -1,5 +1,3 @@
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';
@@ -11,11 +9,4 @@ 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,11 +1,13 @@
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);
@@ -15,15 +17,25 @@ 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;
@@ -54,6 +66,11 @@ 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 {
@@ -73,16 +90,3 @@ 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,19 +8,18 @@ part of 'get_participants_response.dart';
GetParticipantsResponse _$GetParticipantsResponseFromJson( GetParticipantsResponse _$GetParticipantsResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('GetParticipantsResponse', json, ($checkedConvert) {
GetParticipantsResponse( final val = GetParticipantsResponse(
(json['data'] as List<dynamic>) $checkedConvert('data', (v) => _participantsFromJson(v)),
.map( );
(e) => GetParticipantsResponseObject.fromJson( $checkedConvert(
e as Map<String, dynamic>, 'headers',
), (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( return val;
(k, e) => MapEntry(k, e as String), });
);
Map<String, dynamic> _$GetParticipantsResponseToJson( Map<String, dynamic> _$GetParticipantsResponseToJson(
GetParticipantsResponse instance, GetParticipantsResponse instance,
@@ -31,29 +30,36 @@ Map<String, dynamic> _$GetParticipantsResponseToJson(
GetParticipantsResponseObject _$GetParticipantsResponseObjectFromJson( GetParticipantsResponseObject _$GetParticipantsResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => GetParticipantsResponseObject( ) => $checkedCreate('GetParticipantsResponseObject', json, ($checkedConvert) {
(json['attendeeId'] as num).toInt(), final val = GetParticipantsResponseObject(
json['actorType'] as String, $checkedConvert('attendeeId', (v) => (v as num).toInt()),
json['actorId'] as String, $checkedConvert('actorType', (v) => v as String),
json['displayName'] as String, $checkedConvert('actorId', (v) => v as String),
$enumDecode( $checkedConvert('displayName', (v) => v as String),
_$GetParticipantsResponseObjectParticipantTypeEnumMap, $checkedConvert(
json['participantType'], 'participantType',
), (v) => $enumDecode(
(json['lastPing'] as num).toInt(), _$GetParticipantsResponseObjectParticipantTypeEnumMap,
$enumDecode( v,
_$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap, unknownValue: GetParticipantsResponseObjectParticipantType.user,
json['inCall'], ),
), ),
(json['permissions'] as num).toInt(), $checkedConvert('lastPing', (v) => (v as num).toInt()),
(json['attendeePermissions'] as num).toInt(), $checkedConvert('inCall', (v) => (v as num).toInt()),
json['sessionId'] as String?, $checkedConvert('permissions', (v) => (v as num).toInt()),
(json['sessionIds'] as List<dynamic>).map((e) => e as String).toList(), $checkedConvert('attendeePermissions', (v) => (v as num).toInt()),
json['status'] as String?, $checkedConvert('sessionId', (v) => v as String?),
json['statusIcon'] as String?, $checkedConvert(
json['statusMessage'] as String?, 'sessionIds',
json['roomToken'] as String?, (v) => (v as List<dynamic>).map((e) => e as String).toList(),
); ),
$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,
@@ -66,9 +72,7 @@ Map<String, dynamic> _$GetParticipantsResponseObjectToJson(
_$GetParticipantsResponseObjectParticipantTypeEnumMap[instance _$GetParticipantsResponseObjectParticipantTypeEnumMap[instance
.participantType]!, .participantType]!,
'lastPing': instance.lastPing, 'lastPing': instance.lastPing,
'inCall': 'inCall': instance.inCall,
_$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap[instance
.inCall]!,
'permissions': instance.permissions, 'permissions': instance.permissions,
'attendeePermissions': instance.attendeePermissions, 'attendeePermissions': instance.attendeePermissions,
'sessionId': instance.sessionId, 'sessionId': instance.sessionId,
@@ -87,11 +91,3 @@ 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,5 +1,3 @@
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';
@@ -13,11 +11,4 @@ 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,15 +8,21 @@ part of 'get_poll_state_response.dart';
GetPollStateResponse _$GetPollStateResponseFromJson( GetPollStateResponse _$GetPollStateResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('GetPollStateResponse', json, ($checkedConvert) {
GetPollStateResponse( final val = GetPollStateResponse(
GetPollStateResponseObject.fromJson( $checkedConvert(
json['data'] as Map<String, dynamic>, 'data',
), (v) => GetPollStateResponseObject.fromJson(v as Map<String, dynamic>),
) ),
..headers = (json['headers'] as Map<String, dynamic>?)?.map( );
(k, e) => MapEntry(k, e as String), $checkedConvert(
); 'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$GetPollStateResponseToJson( Map<String, dynamic> _$GetPollStateResponseToJson(
GetPollStateResponse instance, GetPollStateResponse instance,
@@ -27,21 +33,30 @@ Map<String, dynamic> _$GetPollStateResponseToJson(
GetPollStateResponseObject _$GetPollStateResponseObjectFromJson( GetPollStateResponseObject _$GetPollStateResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => GetPollStateResponseObject( ) => $checkedCreate('GetPollStateResponseObject', json, ($checkedConvert) {
(json['id'] as num).toInt(), final val = GetPollStateResponseObject(
json['question'] as String, $checkedConvert('id', (v) => (v as num).toInt()),
(json['options'] as List<dynamic>).map((e) => e as String).toList(), $checkedConvert('question', (v) => v as String),
json['votes'], $checkedConvert(
json['actorType'] as String, 'options',
json['actorId'] as String, (v) => (v as List<dynamic>).map((e) => e as String).toList(),
json['actorDisplayName'] as String, ),
(json['status'] as num).toInt(), $checkedConvert('votes', (v) => v),
(json['resultMode'] as num).toInt(), $checkedConvert('actorType', (v) => v as String),
(json['maxVotes'] as num).toInt(), $checkedConvert('actorId', (v) => v as String),
(json['votedSelf'] as List<dynamic>).map((e) => (e as num).toInt()).toList(), $checkedConvert('actorDisplayName', (v) => v as String),
(json['numVoters'] as num?)?.toInt(), $checkedConvert('status', (v) => (v as num).toInt()),
json['details'] as List<dynamic>?, $checkedConvert('resultMode', (v) => (v as num).toInt()),
); $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,
@@ -1,7 +1,3 @@
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_reactions_response.dart'; import 'get_reactions_response.dart';
@@ -15,11 +11,4 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
@override @override
GetReactionsResponse assemble(String raw) => GetReactionsResponse assemble(String raw) =>
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw)); GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -17,6 +17,7 @@ class GetReactionsResponse extends ApiResponse {
@JsonSerializable() @JsonSerializable()
class GetReactionsResponseObject { class GetReactionsResponseObject {
@JsonKey(unknownEnumValue: GetReactionsResponseObjectActorType.unknown)
GetReactionsResponseObjectActorType actorType; GetReactionsResponseObjectActorType actorType;
String actorId; String actorId;
String actorDisplayName; String actorDisplayName;
@@ -39,4 +40,11 @@ enum GetReactionsResponseObjectActorType {
guests, guests,
@JsonValue('users') @JsonValue('users')
users, users,
@JsonValue('emails')
emails,
@JsonValue('bots')
bots,
@JsonValue('federated_users')
federatedUsers,
unknown,
} }
@@ -8,24 +8,32 @@ part of 'get_reactions_response.dart';
GetReactionsResponse _$GetReactionsResponseFromJson( GetReactionsResponse _$GetReactionsResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => ) => $checkedCreate('GetReactionsResponse', json, ($checkedConvert) {
GetReactionsResponse( final val = GetReactionsResponse(
(json['data'] as Map<String, dynamic>).map( $checkedConvert(
(k, e) => MapEntry( 'data',
k, (v) => (v as Map<String, dynamic>).map(
(e as List<dynamic>) (k, e) => MapEntry(
.map( k,
(e) => GetReactionsResponseObject.fromJson( (e as List<dynamic>)
e as Map<String, dynamic>, .map(
), (e) => GetReactionsResponseObject.fromJson(
) e as Map<String, dynamic>,
.toList(), ),
), )
.toList(),
), ),
) ),
..headers = (json['headers'] as Map<String, dynamic>?)?.map( ),
(k, e) => MapEntry(k, e as String), );
); $checkedConvert(
'headers',
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
);
return val;
});
Map<String, dynamic> _$GetReactionsResponseToJson( Map<String, dynamic> _$GetReactionsResponseToJson(
GetReactionsResponse instance, GetReactionsResponse instance,
@@ -38,12 +46,22 @@ Map<String, dynamic> _$GetReactionsResponseToJson(
GetReactionsResponseObject _$GetReactionsResponseObjectFromJson( GetReactionsResponseObject _$GetReactionsResponseObjectFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => GetReactionsResponseObject( ) => $checkedCreate('GetReactionsResponseObject', json, ($checkedConvert) {
$enumDecode(_$GetReactionsResponseObjectActorTypeEnumMap, json['actorType']), final val = GetReactionsResponseObject(
json['actorId'] as String, $checkedConvert(
json['actorDisplayName'] as String, 'actorType',
(json['timestamp'] as num).toInt(), (v) => $enumDecode(
); _$GetReactionsResponseObjectActorTypeEnumMap,
v,
unknownValue: GetReactionsResponseObjectActorType.unknown,
),
),
$checkedConvert('actorId', (v) => v as String),
$checkedConvert('actorDisplayName', (v) => v as String),
$checkedConvert('timestamp', (v) => (v as num).toInt()),
);
return val;
});
Map<String, dynamic> _$GetReactionsResponseObjectToJson( Map<String, dynamic> _$GetReactionsResponseObjectToJson(
GetReactionsResponseObject instance, GetReactionsResponseObject instance,
@@ -58,4 +76,8 @@ Map<String, dynamic> _$GetReactionsResponseObjectToJson(
const _$GetReactionsResponseObjectActorTypeEnumMap = { const _$GetReactionsResponseObjectActorTypeEnumMap = {
GetReactionsResponseObjectActorType.guests: 'guests', GetReactionsResponseObjectActorType.guests: 'guests',
GetReactionsResponseObjectActorType.users: 'users', GetReactionsResponseObjectActorType.users: 'users',
GetReactionsResponseObjectActorType.emails: 'emails',
GetReactionsResponseObjectActorType.bots: 'bots',
GetReactionsResponseObjectActorType.federatedUsers: 'federated_users',
GetReactionsResponseObjectActorType.unknown: 'unknown',
}; };
@@ -1,5 +1,3 @@
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_shared_items_response.dart'; import 'get_shared_items_response.dart';
@@ -30,11 +28,4 @@ class GetSharedItems extends TalkApi<GetSharedItemsResponse> {
@override @override
GetSharedItemsResponse assemble(String raw) => GetSharedItemsResponse assemble(String raw) =>
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw)); GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,5 +1,3 @@
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_shared_items_overview_response.dart'; import 'get_shared_items_overview_response.dart';
@@ -18,11 +16,4 @@ class GetSharedItemsOverview extends TalkApi<GetSharedItemsOverviewResponse> {
@override @override
GetSharedItemsOverviewResponse assemble(String raw) => GetSharedItemsOverviewResponse assemble(String raw) =>
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw)); GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -7,7 +7,12 @@ part of 'react_message_params.dart';
// ************************************************************************** // **************************************************************************
ReactMessageParams _$ReactMessageParamsFromJson(Map<String, dynamic> json) => ReactMessageParams _$ReactMessageParamsFromJson(Map<String, dynamic> json) =>
ReactMessageParams(json['reaction'] as String); $checkedCreate('ReactMessageParams', json, ($checkedConvert) {
final val = ReactMessageParams(
$checkedConvert('reaction', (v) => v as String),
);
return val;
});
Map<String, dynamic> _$ReactMessageParamsToJson(ReactMessageParams instance) => Map<String, dynamic> _$ReactMessageParamsToJson(ReactMessageParams instance) =>
<String, dynamic>{'reaction': instance.reaction}; <String, dynamic>{'reaction': instance.reaction};
@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../json_lenient.dart';
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_room_params.dart'; import 'get_room_params.dart';
@@ -17,14 +17,8 @@ class GetRoom extends TalkApi<GetRoomResponse> {
@override @override
Future<GetRoomResponse> assembleAsync(String raw) => Future<GetRoomResponse> assembleAsync(String raw) =>
compute(_parseRooms, raw); compute(_parseRooms, raw);
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
GetRoomResponse _parseRooms(String raw) => GetRoomResponse _parseRooms(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw)); GetRoomResponse.fromJson(NextcloudOcs.decode(raw))
..parseWarnings = drainDecodeWarnings();
Loaded 100 of 439 files, more files were not shown because too many files have changed in this diff. Show more