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