Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a8fed24bb | ||
|
|
b413fe05de | ||
|
|
38a651f916 | ||
|
|
f98a6979fb | ||
|
|
3e856d0b1c | ||
|
|
8eddc8f771 | ||
|
|
27563a6dc1 | ||
|
|
5e65c4671a | ||
|
|
79b33ab248 | ||
|
|
1fd6264d10 | ||
|
|
2ca9feb49a | ||
|
|
2aed3d104c | ||
|
|
e6f74ca6fe | ||
|
|
0f6c388940 | ||
|
|
d6130584f2 | ||
|
|
56a497985b | ||
|
|
ed8e52f475 | ||
|
|
ef7d17033d | ||
|
|
a71f4f379b | ||
|
|
e21560ed3d | ||
|
|
de866b12b4 | ||
|
|
4f31e68456 | ||
|
|
498d195138 | ||
|
|
84098af7e2 | ||
|
|
630497abdd | ||
|
|
2423c1a75e | ||
|
|
67c935c05b | ||
|
|
e4e2b1a4fb | ||
|
|
3734d7ff2c | ||
|
|
43dfc52bc7 | ||
|
|
f19f212fb4 | ||
|
|
71839d0848 | ||
|
|
65300614b1 | ||
|
|
02a7b87b5b | ||
|
|
bd83a88e70 | ||
|
|
09d4ecda78 | ||
|
|
d27312afdf | ||
|
|
ccb22a497d | ||
|
|
889d8f67c5 | ||
|
|
39c16bd4ea | ||
|
|
b9cb1df473 | ||
|
|
62fa337188 | ||
|
|
c0dadf8b6e | ||
|
|
ab23422a86 | ||
|
|
646e2c0451 | ||
|
|
246cb0f527 | ||
|
|
4c2e9b47e7 | ||
|
|
2d690736e3 | ||
|
|
778c473631 | ||
|
|
7e9cbcf1e9 | ||
|
|
32be67426c | ||
|
|
b957189fd3 | ||
|
|
624c5512a6 | ||
|
|
3493cf8617 | ||
|
|
75080a2c49 | ||
|
|
f3cd7896d9 | ||
|
|
101e7c909c | ||
|
|
429f1e6f96 | ||
|
|
a0c55a811c | ||
|
|
e625216a90 | ||
|
|
8128cede21 | ||
|
|
e5f7cf0176 | ||
|
|
e349d667d4 | ||
|
|
7c1f5c06df | ||
|
|
e8c6ac1c65 | ||
|
|
478f0ff20b | ||
|
|
8274dd46cd | ||
|
|
d7536ea5d0 | ||
|
|
398b147c76 |
No files matched your search
@@ -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. Zwei Kontoarten: Schul-Konto (Schüler/Lehrer, Benutzername + Passwort) und Eltern-Konto (passwortlos per E-Mail-Code/App-Link, sieht die Stundenpläne der zugeordneten Kinder und erhält Elternbriefe, kein Talk/Files).
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -16,7 +16,10 @@ 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 (CredentialSession / GuardianSession), SessionManager, SessionLifecycle
|
||||
├── access/ UserRole, AccessRequirement (Gating von Modulen/Settings)
|
||||
├── auth_link/ Eltern-Login: App-Link-Listener, Link-Parser, Geräte-Bindung
|
||||
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
|
||||
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
||||
├── view/ Screens
|
||||
@@ -51,6 +54,16 @@ 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()` – Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
|
||||
|
||||
**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; der Request-Cache `CacheStore` folgt dem aktiven Konto mit eigenem Verzeichnis); nur die Settings liegen in der globalen Box. `SessionManager().sessionEpoch` steigt bei jedem Kontowechsel – asynchrone Arbeit prüft damit, ob ihr Ergebnis noch zum aktiven Konto gehört. Was ein Konto außerhalb seines Speichers hinterlässt (Tray, Push-Schlüssel, Downloads), räumt `SessionWipe` auf. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert.
|
||||
|
||||
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`nextcloud`, `guardian`; `AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung.
|
||||
|
||||
**Stundenplan:** Ein `TimetableBloc(subject: …)` für alle Fälle (eigener Plan, Fremdplan, Kind). Den globalen Bloc stellt `PrimaryTimetableScope` bereit und tauscht ihn beim Kindwechsel aus; page-scoped Fremdpläne nutzen `ScopedTimetableBloc`. Die Kinderauswahl (`ChildSelectionCubit`) ist modulübergreifend.
|
||||
|
||||
**Elternbriefe:** Modul `parentLetters` (nur Eltern-Sessions, `AccessRequirement.guardian`): Lehrer-Mitteilungen aus MarianumConnect mit Gelesen-Status, Kenntnisnahme/Auswahl/Unterschrift pro Kind, Thread und Anhängen. Der Posteingang (`ParentLettersBloc`) ist global (Modul-Badge, Push-Refresh), ein Brief ist page-scoped (`ParentLetterBloc`). Ob und wie geantwortet werden darf, entscheidet der Server (`editable`); `ParentLetterFormPolicy.resolve` macht daraus das Formular. Push: Connect-Direct-Push `type: parent-letter`, Tap-Routing allein über `parentLetterId`. Alle Benachrichtigungs-Taps (lokal gerendert wie FCM) werden von `resolvePushTarget` (`lib/push/push_target.dart`) aufgelöst und in `NotificationTasks.openPushTarget` navigiert – neue Push-Ziele nur dort ergänzen.
|
||||
|
||||
## Build / Run
|
||||
|
||||
```bash
|
||||
@@ -65,13 +78,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, Elternbriefe, 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, Policies, Eltern-Login-Controller). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
|
||||
@@ -18,6 +18,9 @@ analyzer:
|
||||
- "**/*.freezed.dart"
|
||||
- "lib/firebase_options.dart"
|
||||
- "build/**"
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
|
||||
linter:
|
||||
rules:
|
||||
|
||||
@@ -11,3 +11,4 @@ GeneratedPluginRegistrant.java
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
.kotlin/
|
||||
@@ -33,10 +33,6 @@ android {
|
||||
coreLibraryDesugaringEnabled true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
@@ -47,7 +43,6 @@ android {
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -59,11 +54,20 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.android.support:multidex:2.0.1'
|
||||
// Same version as workmanager_android pins — needed to enqueue its
|
||||
// BackgroundWorker from native widget code (the plugin uses
|
||||
// `implementation`, so androidx.work is not exposed transitively).
|
||||
implementation 'androidx.work:work-runtime:2.11.2'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
@@ -41,6 +41,23 @@
|
||||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
</intent-filter>
|
||||
<!-- Guardian login mail link (App Link). Verified via
|
||||
/.well-known/assetlinks.json on both hosts. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="connect.marianum-fulda.de" />
|
||||
<data android:host="connect-beta.marianum-fulda.de" />
|
||||
<data android:path="/app/guardian-login" />
|
||||
</intent-filter>
|
||||
<!-- The app routes links itself (GuardianLinkListener); Flutter's
|
||||
built-in handling would push the path as a named route onto a
|
||||
MaterialApp that only has `home`. -->
|
||||
<meta-data
|
||||
android:name="flutter_deeplinking_enabled"
|
||||
android:value="false" />
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
@@ -81,6 +98,7 @@
|
||||
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
||||
<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 +111,7 @@
|
||||
|
||||
<receiver
|
||||
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
|
||||
android:enabled="@bool/widget_supported"
|
||||
android:label="@string/widget_week_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// Generated file.
|
||||
//
|
||||
// If you wish to remove Flutter's multidex support, delete this entire file.
|
||||
//
|
||||
// Modifications to this file should be done in a copy under a different name
|
||||
// as this file may be regenerated.
|
||||
|
||||
package io.flutter.app;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import androidx.annotation.CallSuper;
|
||||
import androidx.multidex.MultiDex;
|
||||
|
||||
/**
|
||||
* Extension of {@link android.app.Application}, adding multidex support.
|
||||
*/
|
||||
public class FlutterMultiDexApplication extends Application {
|
||||
@Override
|
||||
@CallSuper
|
||||
protected void attachBaseContext(Context base) {
|
||||
super.attachBaseContext(base);
|
||||
MultiDex.install(this);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,38 @@
|
||||
package eu.mhsl.marianum.mobile.client
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val widgetChannel = "eu.mhsl.marianum.widget"
|
||||
private val shareChannel = "eu.mhsl.marianum.share"
|
||||
/// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation`
|
||||
/// so the same intent isn't replayed on every resume.
|
||||
private var pendingTimetableTap: Boolean = false
|
||||
private val shareActions = setOf(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE)
|
||||
|
||||
/// receive_sharing_intent only emits warm shares to a live Dart listener and
|
||||
/// does not buffer them. After a relaunch into an existing task the share
|
||||
/// arrives via onNewIntent before Dart subscribed, so it is held here until
|
||||
/// Dart reports `listenerReady`.
|
||||
private var shareListenerReady = false
|
||||
private var bufferedShare: Intent? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Android replays the original launch intent when the activity is
|
||||
// recreated from recents or after process death. A share or widget tap
|
||||
// that was already handled would otherwise run again — for shares with
|
||||
// temp files that are long gone.
|
||||
val replayed = savedInstanceState != null ||
|
||||
(intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0
|
||||
if (replayed && (intent.action in shareActions || isWidgetTap(intent))) {
|
||||
intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
@@ -26,17 +49,35 @@ class MainActivity : FlutterActivity() {
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
shareChannel
|
||||
).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"listenerReady" -> {
|
||||
shareListenerReady = true
|
||||
bufferedShare?.let { flutterEngine.activityControlSurface.onNewIntent(it) }
|
||||
bufferedShare = null
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
consumeIntentData(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
if (!shareListenerReady && intent.action in shareActions) {
|
||||
bufferedShare = intent
|
||||
}
|
||||
consumeIntentData(intent)
|
||||
}
|
||||
|
||||
private fun isWidgetTap(intent: Intent?): Boolean =
|
||||
intent?.getBooleanExtra("widget_open_timetable", false) == true
|
||||
|
||||
private fun consumeIntentData(intent: Intent?) {
|
||||
if (intent?.getBooleanExtra("widget_open_timetable", false) == true) {
|
||||
pendingTimetableTap = true
|
||||
}
|
||||
if (isWidgetTap(intent)) pendingTimetableTap = true
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||
|
||||
/**
|
||||
@@ -11,6 +12,11 @@ import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||
* Flutter plugin resolves the receiver class as `<app-package>.<androidName>`.
|
||||
*/
|
||||
class TimetableDayWidget : AppWidgetProvider() {
|
||||
override fun onEnabled(context: Context) {
|
||||
// First widget of this kind placed → fetch fresh data right away.
|
||||
WidgetRefreshRequester.enqueueOneOffRefresh(context)
|
||||
}
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
|
||||
@@ -4,9 +4,15 @@ import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||
|
||||
class TimetableWeekWidget : AppWidgetProvider() {
|
||||
override fun onEnabled(context: Context) {
|
||||
// First widget of this kind placed → fetch fresh data right away.
|
||||
WidgetRefreshRequester.enqueueOneOffRefresh(context)
|
||||
}
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -33,10 +34,15 @@ data class WidgetLesson(
|
||||
val subjectShort: String,
|
||||
val subjectLong: String?,
|
||||
val room: String?,
|
||||
// On teacher accounts this carries the class label ("7a") instead of the
|
||||
// teacher short name (originalTeacher is null then) — mapped in Dart.
|
||||
val teacher: String?,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -48,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,
|
||||
@@ -55,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 {
|
||||
@@ -79,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 {
|
||||
@@ -100,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),
|
||||
)
|
||||
}
|
||||
@@ -118,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(),
|
||||
@@ -125,6 +157,7 @@ object WidgetDataParser {
|
||||
periods = periods,
|
||||
isHoliday = root.optBoolean("isHoliday", false),
|
||||
holidayName = root.stringOrNull("holidayName"),
|
||||
days = days,
|
||||
)
|
||||
} catch (_: JSONException) {
|
||||
null
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package eu.mhsl.marianum.mobile.client.widgets
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import dev.fluttercommunity.workmanager.BackgroundWorker
|
||||
|
||||
/**
|
||||
* Enqueues the app's Dart one-off widget refresh from native code, so a
|
||||
* freshly placed widget populates within seconds instead of waiting for the
|
||||
* next periodic slot.
|
||||
*
|
||||
* DART_TASK_KEY is public plugin API but no semver guarantee — re-verify on
|
||||
* workmanager upgrades. The Dart callback handle comes from SharedPreferences
|
||||
* persisted by Workmanager().initialize(); if the app never ran, the worker
|
||||
* fails gracefully and the widget keeps rendering its cached/empty state.
|
||||
*/
|
||||
object WidgetRefreshRequester {
|
||||
// Mirrors WidgetBackgroundTask.oneOffTaskName on the Dart side.
|
||||
private const val DART_ONE_OFF_TASK = "eu.mhsl.marianum.widget.refresh.once"
|
||||
|
||||
fun enqueueOneOffRefresh(context: Context) {
|
||||
val request = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
|
||||
.setInputData(
|
||||
Data.Builder()
|
||||
.putString(BackgroundWorker.DART_TASK_KEY, DART_ONE_OFF_TASK)
|
||||
.build(),
|
||||
)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueue(request)
|
||||
}
|
||||
}
|
||||
+593
-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="#FF993333" />
|
||||
</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="#FFEF6C00" />
|
||||
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
|
||||
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: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:singleLine="true"
|
||||
android:maxLines="1"
|
||||
android:text="Heute · 06.05." />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="10sp"
|
||||
android:textColor="@color/widget_text_secondary"
|
||||
android:text="Stand: 07:50" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
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">
|
||||
<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" />
|
||||
|
||||
<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" />
|
||||
<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="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="29dp" android:background="@color/widget_divider" />
|
||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="56dp" android:background="@color/widget_divider" />
|
||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="83dp" android:background="@color/widget_divider" />
|
||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="115dp" android:background="@color/widget_divider" />
|
||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="143dp" android:background="@color/widget_divider" />
|
||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="170dp" android:background="@color/widget_divider" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="22dp"
|
||||
android:layout_marginTop="29dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:layout_marginEnd="2dp"
|
||||
android:padding="3dp"
|
||||
android:background="@drawable/widget_lesson_block_regular">
|
||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
||||
android:textSize="11sp" android:textStyle="bold"
|
||||
android:textColor="@android:color/white" android:text="MA-LK" />
|
||||
</FrameLayout>
|
||||
|
||||
<FrameLayout
|
||||
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="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="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="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="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="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="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:gravity="top"
|
||||
android:baselineAligned="false">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_lesson_subject"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="14dp"
|
||||
android:layout_weight="1"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
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"
|
||||
android:autoSizeMinTextSize="6.5dp"
|
||||
android:autoSizeMaxTextSize="15dp"
|
||||
android:autoSizeStepGranularity="0.5dp"
|
||||
tools:text="MA-LK" />
|
||||
|
||||
<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">
|
||||
<!-- 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_room"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="12dp"
|
||||
android:textColor="#CCFFFFFF"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="10dp"
|
||||
android:textColor="@android:color/white"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:gravity="end|top"
|
||||
android:gravity="start|center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:autoSizeTextType="uniform"
|
||||
android:autoSizeMinTextSize="5sp"
|
||||
android:autoSizeMaxTextSize="11sp"
|
||||
android:autoSizeStepGranularity="1sp"
|
||||
android:autoSizeMinTextSize="5.5dp"
|
||||
android:autoSizeMaxTextSize="10dp"
|
||||
android:autoSizeStepGranularity="0.5dp"
|
||||
tools:text="A12" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_lesson_teacher"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="12dp"
|
||||
android:textColor="#B3FFFFFF"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="10dp"
|
||||
android:textColor="@android:color/white"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:gravity="end|top"
|
||||
android:gravity="start|center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:autoSizeTextType="uniform"
|
||||
android:autoSizeMinTextSize="5sp"
|
||||
android:autoSizeMaxTextSize="11sp"
|
||||
android:autoSizeStepGranularity="1sp"
|
||||
android:autoSizeMinTextSize="5.5dp"
|
||||
android:autoSizeMaxTextSize="10dp"
|
||||
android:autoSizeStepGranularity="0.5dp"
|
||||
tools:text="Müller" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/widget_lesson_cancelled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/widget_lesson_cancelled_overlay"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
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">
|
||||
|
||||
<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: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: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>
|
||||
android:maxLines="1"
|
||||
android:text="KW 19 · 04.05.–08.05." />
|
||||
|
||||
<!-- 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 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="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="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="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="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>
|
||||
+18
-3
@@ -5,10 +5,25 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = '../build'
|
||||
rootProject.layout.buildDirectory = file('../build')
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
project.layout.buildDirectory = rootProject.layout.buildDirectory.dir(project.name)
|
||||
}
|
||||
// AGP 9 enables built-in Kotlin, but Flutter's migrator pins
|
||||
// android.builtInKotlin=false while the plugin ecosystem catches up. Most
|
||||
// plugins honour that flag and fall back to KGP themselves; file_picker and
|
||||
// background_downloader only check the AGP major version and silently skip
|
||||
// applying KGP, which leaves their Kotlin sources uncompiled (missing
|
||||
// FilePickerPlugin/BackgroundDownloaderPlugin at link time). Apply KGP for
|
||||
// them as soon as AGP is on the project, i.e. before their android {} block
|
||||
// runs. Drop this once both plugins ship a builtInKotlin-aware build script.
|
||||
gradle.beforeProject { sub ->
|
||||
if (!(sub.name in ['file_picker', 'background_downloader'])) return
|
||||
sub.plugins.withId('com.android.library') {
|
||||
sub.pluginManager.apply('org.jetbrains.kotlin.android')
|
||||
}
|
||||
}
|
||||
|
||||
// Pin every Android subproject to JVM 17 so plugins that ship Kotlin sources
|
||||
// compiled with a higher target (e.g. receive_sharing_intent at 21) or stale
|
||||
// Java compatibility (e.g. home_widget at 1.8) don't break the build under
|
||||
@@ -42,5 +57,5 @@ subprojects {
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
delete rootProject.layout.buildDirectory
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx4G
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=false
|
||||
android.nonFinalResIds=false
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -19,9 +19,9 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version '8.13.2' apply false
|
||||
id "com.android.library" version '8.13.2' apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.2.20" apply false
|
||||
id "com.android.application" version '9.1.0' apply false
|
||||
id "com.android.library" version '9.1.0' apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.4.0" apply false
|
||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
#
|
||||
# Hinweis: Die Steuerzeichen ("---") dürfen NICHT entfernt werden!
|
||||
# Nach dem zweiten Steuerzeichen wird als Markdown interpretiert und es sind keine Kommentare mehr möglich!
|
||||
# Kommentare sind nur innerhalb des Steuerblocks zugelassen, sowie die Variablen.
|
||||
#
|
||||
# Notfall-Nachricht der MarianumMobile-App
|
||||
#
|
||||
# Diese Datei wird bei jedem App-Start geladen.
|
||||
# Solange 'active' nicht true ist, wird NICHTS angezeigt (Normalzustand).
|
||||
#
|
||||
# Steuerfelder:
|
||||
# active: true schaltet die Anzeige ein (Default: false)
|
||||
# dismissible: true = wegklickbar & gecachte Inhalte der App weiterhin normal sichtbar, false = Vollbild & nicht schließbar (Default: true)
|
||||
# title: optionale Überschrift
|
||||
#
|
||||
# Im Notfall: 'active: false' auf 'active: true' setzen, unten Inhalt anpassen.
|
||||
# Der Textinhalt wird in Markdown ausgewertet. Siehe https://markdownlivepreview.com/
|
||||
# VORSICHT: Hashtags (#) sind in Markdown kein Kommentar sondern "Titel"!
|
||||
# Beispielkonfiguration:
|
||||
#
|
||||
# ---
|
||||
# # Ein Kommentar
|
||||
# active: true
|
||||
# dismissible: false
|
||||
# title: Wichtiger Hinweis
|
||||
# ---
|
||||
# Hinweistext in Markdown
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
active: false
|
||||
dismissible: true
|
||||
title: Hinweis
|
||||
---
|
||||
# Serverstörung
|
||||
Der Zugriff auf einige Funktionen ist derzeit großflächig **eingeschränkt**. Wir arbeiten an einer Lösung.
|
||||
Bitte prüfe unter folgendem Link auf aktuelle Informationen der Schulleitung.
|
||||
|
||||
- Aktuelle Informationen: [www.marianum-fulda.de](https://www.marianum-fulda.de)
|
||||
@@ -106,12 +106,14 @@ void _log(String message) => debugPrint('SHOTS: $message');
|
||||
Future<void> _login(WidgetTester tester) async {
|
||||
final loginVisible = await _pumpUntil(
|
||||
tester,
|
||||
find.byKey(const Key('login-username-field')),
|
||||
find.byKey(const Key('login-audience-school')),
|
||||
);
|
||||
if (!loginVisible) {
|
||||
_log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login');
|
||||
return;
|
||||
}
|
||||
await tester.tap(find.byKey(const Key('login-audience-school')));
|
||||
await _pumpUntil(tester, find.byKey(const Key('login-username-field')));
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('login-username-field')),
|
||||
'demo@screenshots',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -31,7 +31,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
/// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart
|
||||
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
|
||||
/// extension. Wrong value here => keychain reads return nil => placeholder.
|
||||
private static let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget"
|
||||
private static let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
|
||||
|
||||
private static let devicePrivateKeyAccount = "push_device_private_key_pem"
|
||||
private static let serverPublicKeyAccount = "push_server_public_key_pem"
|
||||
@@ -233,7 +233,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
/// kSecClass = kSecClassGenericPassword
|
||||
/// kSecAttrAccount = the Dart key, verbatim
|
||||
/// kSecAttrService = (unset — the Dart IOSOptions set no accountName)
|
||||
/// kSecAttrAccessGroup = the App Group id
|
||||
/// kSecAttrAccessGroup = the team-prefixed shared keychain group
|
||||
/// value = raw UTF-8 bytes of the string
|
||||
private func keychainString(_ account: String) -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
+38
-12
@@ -55,8 +55,9 @@ iOS zeigt die fertige Notification
|
||||
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
|
||||
| `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
|
||||
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
|
||||
| **Xcode-Target „NotificationServiceExtension"** | **FEHLT** | muss in Xcode angelegt werden (Abschnitt 3) |
|
||||
| `ios/Runner.xcodeproj/project.pbxproj` | **unverändert** | bewusst NICHT von Hand editiert — Xcode legt das Target an |
|
||||
| **Xcode-Target „NotificationServiceExtension"** | **existiert** | programmatisch via `xcodeproj`-Gem angelegt (2026-07-07), gespiegelt an der Share-Extension |
|
||||
| `ios/Runner.xcodeproj/project.pbxproj` | **geändert** | NSE-Target, Dependency + „Embed Foundation Extensions" ergänzt |
|
||||
| `ios/Flutter/NotificationServiceExtension-{Debug,Release,Profile}.xcconfig` | **neu** | Base-Configs, inkludieren `Generated.xcconfig` (Flutter-Versionsvariablen) |
|
||||
|
||||
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
|
||||
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
|
||||
@@ -67,6 +68,15 @@ iOS zeigt die fertige Notification
|
||||
|
||||
## 3. Xcode-Checkliste (auf dem Mac)
|
||||
|
||||
> **Stand 2026-07-07:** Abschnitte 3.1–3.2 (Target anlegen, Dateien zuordnen) sind
|
||||
> bereits **programmatisch** erledigt (via `xcodeproj`-Gem). Der unsignierte Build
|
||||
> aller Targets läuft durch (`flutter build ios --no-codesign`), die
|
||||
> `NotificationServiceExtension.appex` wird korrekt in `Runner.app/PlugIns/`
|
||||
> eingebettet. **Offen bleiben nur noch Signing/Capabilities (3.3–3.4, 3.6)** —
|
||||
> die brauchen den Apple-Developer-Account und einen signierten Build/Archive.
|
||||
> Die 3.1/3.2-Anleitung unten bleibt als Referenz stehen (falls das Target mal neu
|
||||
> aufgesetzt werden muss).
|
||||
|
||||
### 3.1 Target anlegen
|
||||
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
|
||||
2. **File → New → Target… → iOS → Notification Service Extension**.
|
||||
@@ -132,7 +142,18 @@ iOS zeigt die fertige Notification
|
||||
## 4. Ermittelte Keychain-Details (verbindlich)
|
||||
|
||||
Die Dart-Seite schreibt mit
|
||||
`IOSOptions(groupId: 'group.eu.mhsl.marianum.mobile.client.widget', accessibility: first_unlock)`.
|
||||
`IOSOptions(groupId: 'MY55VF3KPG.eu.mhsl.marianum.mobile.client.push', accessibility: first_unlock)`.
|
||||
|
||||
> **Wichtig (Stand 2026-07-07):** Als Keychain-Access-Group wird **nicht** mehr die
|
||||
> App-Group (`group.*`) genutzt, sondern eine **team-prefixed** Group
|
||||
> (`$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push`). Grund: Die
|
||||
> Xcode-verwalteten Provisioning-Profile gewähren als `keychain-access-groups`
|
||||
> nur `<TeamID>.*` — eine `group.*`-App-Group fällt da **nicht** drunter, was
|
||||
> Automatic Signing mit „doesn't match the entitlements file's value for the
|
||||
> keychain-access-groups entitlement" abbricht. Runner und NSE teilen die Group,
|
||||
> weil sie mit demselben Team (`MY55VF3KPG`) signieren. Der `MY55VF3KPG.`-Prefix
|
||||
> ist der stabile AppIdentifierPrefix und in Dart/Swift hart hinterlegt.
|
||||
|
||||
Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
|
||||
`pubspec.lock`) ergibt sich die exakte Ablage im Keychain:
|
||||
|
||||
@@ -141,7 +162,7 @@ Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
|
||||
| `kSecClass` | `kSecClassGenericPassword` |
|
||||
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
|
||||
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
|
||||
| `kSecAttrAccessGroup` | `group.eu.mhsl.marianum.mobile.client.widget` |
|
||||
| `kSecAttrAccessGroup` | `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push` (team-prefixed) |
|
||||
| `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) |
|
||||
| Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) |
|
||||
|
||||
@@ -159,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
|
||||
@@ -278,9 +299,14 @@ ist der fragilste Teil und **muss auf dem Gerät verifiziert werden**:
|
||||
innerhalb des NSE-Budgets). Nicht implementiert.
|
||||
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
|
||||
vor dem Release erledigen und im Archive gegenchecken (5.2).
|
||||
4. **Keychain-Access-Group-Schreibweise.** Die Entitlements listen die App-Group
|
||||
ohne `$(AppIdentifierPrefix)` als `keychain-access-groups`. Das ist das von
|
||||
`flutter_secure_storage` erwartete Verhalten (Access-Group == App-Group-ID).
|
||||
Sollte der Keychain-Zugriff wider Erwarten scheitern (Status `-34018` /
|
||||
`errSecMissingEntitlement`), in **beiden** Targets die Keychain-Sharing-
|
||||
Capability über die Xcode-UI neu setzen und Provisioning-Profile erneuern.
|
||||
4. **Keychain-Access-Group-Schreibweise (gelöst 2026-07-07).** Die Entitlements
|
||||
listen `$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push` als
|
||||
`keychain-access-groups` (team-prefixed, **keine** App-Group). Damit greift das
|
||||
`<TeamID>.*` der Xcode-Profile und Automatic Signing läuft ohne Portal-Änderung
|
||||
durch (verifiziert: `flutter build ios --release` signiert Runner **und** NSE
|
||||
mit `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push`). Der frühere App-Group-
|
||||
Ansatz (`group.*`) scheiterte an genau diesem Profil-Matching. Falls der
|
||||
Keychain-Zugriff zur Laufzeit doch scheitert (Status `-34018` /
|
||||
`errSecMissingEntitlement`), prüfen, dass Dart (`push_secure_storage.dart`) und
|
||||
Swift (`AppDelegate.swift`, `NotificationService.swift`) **exakt denselben**
|
||||
vollqualifizierten Group-String verwenden.
|
||||
+2
-20
@@ -4,8 +4,6 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_app_badge (2.0.0):
|
||||
- Flutter
|
||||
- home_widget (0.0.1):
|
||||
- Flutter
|
||||
- open_filex (0.0.2):
|
||||
- Flutter
|
||||
- PhoneNumberKit (3.7.11):
|
||||
@@ -14,20 +12,13 @@ PODS:
|
||||
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
|
||||
- PhoneNumberKit/UIKit (3.7.11):
|
||||
- PhoneNumberKit/PhoneNumberKitCore
|
||||
- receive_sharing_intent (1.8.1):
|
||||
- Flutter
|
||||
- workmanager_apple (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- eraser (from `.symlinks/plugins/eraser/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
||||
- home_widget (from `.symlinks/plugins/home_widget/ios`)
|
||||
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
||||
- PhoneNumberKit (~> 3.7.6)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@@ -40,25 +31,16 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_app_badge:
|
||||
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
||||
home_widget:
|
||||
:path: ".symlinks/plugins/home_widget/ios"
|
||||
open_filex:
|
||||
:path: ".symlinks/plugins/open_filex/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
workmanager_apple:
|
||||
:path: ".symlinks/plugins/workmanager_apple/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
||||
|
||||
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
COCOAPODS: 1.17.0
|
||||
@@ -3,23 +3,28 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 70;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
034BD2FF7A860C6DC2FED514 /* Pods_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */; };
|
||||
0B36AA42294848D99E4E0742 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */; };
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2960F029246C39E2A03F6D87 /* NotificationService.swift */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509DCCD474353408FE5806C5 /* PEM.swift */; };
|
||||
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
|
||||
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F5B421EAF56B77B775E58E92 /* Foundation.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -37,6 +42,13 @@
|
||||
remoteGlobalIDString = AA0101010000000011111111;
|
||||
remoteInfo = TimetableWidgetExtension;
|
||||
};
|
||||
AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = AEDC710FEBFF2CC736D88AB2;
|
||||
remoteInfo = NotificationServiceExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -48,6 +60,7 @@
|
||||
files = (
|
||||
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
|
||||
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
|
||||
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -65,17 +78,24 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
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; 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; 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>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -88,16 +108,19 @@
|
||||
AA0101020000000011111111 /* TimetableWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimetableWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AA0102020000000022222222 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
AA6B03D1433E7395021F7730 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
BB0001010000000011111111 /* ShareExtension-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Debug.xcconfig"; path = "Flutter/ShareExtension-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||
BB0001020000000011111111 /* ShareExtension-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Release.xcconfig"; path = "Flutter/ShareExtension-Release.xcconfig"; sourceTree = "<group>"; };
|
||||
BB0001030000000011111111 /* ShareExtension-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Profile.xcconfig"; path = "Flutter/ShareExtension-Profile.xcconfig"; sourceTree = "<group>"; };
|
||||
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; };
|
||||
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; };
|
||||
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
F5B421EAF56B77B775E58E92 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Profile.xcconfig"; path = "Flutter/NotificationServiceExtension-Profile.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -169,6 +192,14 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D2447D92E9CCD96B3292B17B /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -185,15 +216,35 @@
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2960F029246C39E2A03F6D87 /* NotificationService.swift */,
|
||||
509DCCD474353408FE5806C5 /* PEM.swift */,
|
||||
12B96C78930441C73F123636 /* Info.plist */,
|
||||
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
|
||||
);
|
||||
path = NotificationServiceExtension;
|
||||
sourceTree = SOURCE_ROOT;
|
||||
};
|
||||
731388A08E3B330B216381D0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
|
||||
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
|
||||
80D9B9919D3D7CCA2A80C8C5 /* iOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
80D9B9919D3D7CCA2A80C8C5 /* iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F5B421EAF56B77B775E58E92 /* Foundation.framework */,
|
||||
);
|
||||
name = iOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -208,6 +259,9 @@
|
||||
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
|
||||
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
|
||||
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
|
||||
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */,
|
||||
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */,
|
||||
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
@@ -222,6 +276,7 @@
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
345F4BD4143471FDA71626DE /* Pods */,
|
||||
731388A08E3B330B216381D0 /* Frameworks */,
|
||||
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -231,6 +286,7 @@
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
|
||||
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
|
||||
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -249,6 +305,7 @@
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
AA0102020000000022222222 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
@@ -278,9 +335,6 @@
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
@@ -299,8 +353,12 @@
|
||||
dependencies = (
|
||||
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
|
||||
AA0101090000000011111111 /* PBXTargetDependency */,
|
||||
43763BD5552A36CA28890DFA /* PBXTargetDependency */,
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -325,13 +383,27 @@
|
||||
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */;
|
||||
buildPhases = (
|
||||
DF8C1170E7DF96711030EAC0 /* Sources */,
|
||||
D2447D92E9CCD96B3292B17B /* Frameworks */,
|
||||
239068341DC6E6B36193EC96 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = NotificationServiceExtension;
|
||||
productName = NotificationServiceExtension;
|
||||
productReference = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
@@ -360,6 +432,9 @@
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
@@ -367,11 +442,19 @@
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
3321F8042FB1C00C0011C712 /* Share Extension */,
|
||||
AA0101010000000011111111 /* TimetableWidgetExtension */,
|
||||
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
239068341DC6E6B36193EC96 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
3321F8032FB1C00C0011C712 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -388,6 +471,7 @@
|
||||
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
0B36AA42294848D99E4E0742 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -520,6 +604,15 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
DF8C1170E7DF96711030EAC0 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */,
|
||||
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
@@ -528,6 +621,12 @@
|
||||
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
|
||||
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
|
||||
};
|
||||
43763BD5552A36CA28890DFA /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = NotificationServiceExtension;
|
||||
target = AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */;
|
||||
targetProxy = AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */;
|
||||
};
|
||||
AA0101090000000011111111 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
|
||||
@@ -929,6 +1028,29 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A2B046F78AD55232F08583F3 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = MY55VF3KPG;
|
||||
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA01010A0000000011111111 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
|
||||
@@ -1051,6 +1173,54 @@
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
E5EE58583E83B7694F6C3C5E /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = MY55VF3KPG;
|
||||
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
F418DF43742DC2E30B161652 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = MY55VF3KPG;
|
||||
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -1094,13 +1264,25 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
E5EE58583E83B7694F6C3C5E /* Release */,
|
||||
A2B046F78AD55232F08583F3 /* Debug */,
|
||||
F418DF43742DC2E30B161652 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
|
||||
"version" : "11.2.0"
|
||||
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
|
||||
"version" : "11.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -50,8 +50,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60",
|
||||
"version" : "12.13.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" : "19dffda9a9caf8d86570ff846535902d8509d7bf",
|
||||
"version" : "3.5.0"
|
||||
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
|
||||
"version" : "3.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -68,8 +68,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||
"state" : {
|
||||
"revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604",
|
||||
"version" : "12.13.0"
|
||||
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
|
||||
"version" : "12.19.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -86,8 +86,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
|
||||
"version" : "8.1.0"
|
||||
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
|
||||
"version" : "8.1.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -122,8 +122,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/leveldb.git",
|
||||
"state" : {
|
||||
"revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
|
||||
"version" : "1.22.2"
|
||||
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||
"version" : "1.22.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -131,8 +131,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/nanopb.git",
|
||||
"state" : {
|
||||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
|
||||
"version" : "2.30910.0"
|
||||
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
|
||||
"version" : "2.30910.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -140,8 +140,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/promises.git",
|
||||
"state" : {
|
||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
||||
"version" : "2.4.0"
|
||||
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||
"version" : "2.4.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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import workmanager_apple
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
@@ -12,7 +13,7 @@ import UserNotifications
|
||||
private let markReadActionId = "TALK_MARK_READ"
|
||||
|
||||
// Shared (App Group) keychain — same group as the NSE and the Dart side.
|
||||
private let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget"
|
||||
private let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
|
||||
private let usernameAccount = "nextcloud_username"
|
||||
private let appPasswordAccount = "nextcloud_app_password"
|
||||
private let baseUrlAccount = "nextcloud_base_url"
|
||||
@@ -23,6 +24,19 @@ import UserNotifications
|
||||
) -> Bool {
|
||||
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
registerTalkCategory()
|
||||
// BGAppRefresh for the home-screen widget. Must all happen before
|
||||
// didFinishLaunching returns: with the UIScene lifecycle Flutter registers
|
||||
// plugins only during scene connection, which is past BGTaskScheduler's
|
||||
// registration deadline — registerLaunchHandlers() bridges that gap.
|
||||
// The task identifier mirrors WidgetBackgroundTask.periodicTaskName; the
|
||||
// plugin re-submits the refresh request itself after every run.
|
||||
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
|
||||
GeneratedPluginRegistrant.register(with: registry)
|
||||
}
|
||||
WorkmanagerPlugin.registerPeriodicTask(
|
||||
withIdentifier: "eu.mhsl.marianum.widget.refresh",
|
||||
earliestBeginInSeconds: 1800)
|
||||
WorkmanagerPlugin.registerLaunchHandlers()
|
||||
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
|
||||
// forwards these callbacks to the plugins (firebase_messaging,
|
||||
// flutter_local_notifications). We route Talk actions natively here — the
|
||||
|
||||
@@ -37,12 +37,14 @@
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>FlutterDeepLinkingEnabled</key>
|
||||
<false/>
|
||||
<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>
|
||||
<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>
|
||||
<string>Um Fotos und Videos mit anderen zu teilen, wird Zugriff auf deine Mediathek benötigt.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
@@ -66,6 +68,10 @@
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>eu.mhsl.marianum.widget.refresh</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -4,6 +4,11 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:connect.marianum-fulda.de</string>
|
||||
<string>applinks:connect-beta.marianum-fulda.de</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
@@ -11,7 +16,7 @@
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -13,21 +13,44 @@ class SceneDelegate: FlutterSceneDelegate {
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
for context in connectionOptions.urlContexts {
|
||||
_ = SwiftReceiveSharingIntentPlugin.instance.application(
|
||||
var handledShare = false
|
||||
for context in connectionOptions.urlContexts where isShareUrl(context.url) {
|
||||
_ = ReceiveSharingIntentPlugin.instance.application(
|
||||
UIApplication.shared,
|
||||
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
|
||||
)
|
||||
handledShare = true
|
||||
}
|
||||
if handledShare { clearSharedPayload() }
|
||||
}
|
||||
|
||||
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
for context in URLContexts {
|
||||
_ = SwiftReceiveSharingIntentPlugin.instance.application(
|
||||
let shares = URLContexts.filter { isShareUrl($0.url) }
|
||||
for context in shares {
|
||||
_ = ReceiveSharingIntentPlugin.instance.application(
|
||||
UIApplication.shared,
|
||||
open: context.url,
|
||||
options: [:]
|
||||
)
|
||||
}
|
||||
if !shares.isEmpty { clearSharedPayload() }
|
||||
let others = URLContexts.subtracting(shares)
|
||||
if !others.isEmpty { super.scene(scene, openURLContexts: others) }
|
||||
}
|
||||
|
||||
private func isShareUrl(_ url: URL) -> Bool {
|
||||
ReceiveSharingIntentPlugin.instance.hasMatchingSchemePrefix(url: url)
|
||||
}
|
||||
|
||||
// The plugin keeps the payload in memory once read but never removes it
|
||||
// from the App Group defaults. The Share Extension opens the app twice per
|
||||
// share and a scene reconnect replays the URL, so every later delivery
|
||||
// would publish the finished share again.
|
||||
private func clearSharedPayload() {
|
||||
let groupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String
|
||||
let defaults = UserDefaults(
|
||||
suiteName: groupId ?? "group.\(Bundle.main.bundleIdentifier ?? "")")
|
||||
defaults?.removeObject(forKey: "ShareKey")
|
||||
defaults?.removeObject(forKey: "ShareMessageKey")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -3,7 +3,7 @@ import UniformTypeIdentifiers
|
||||
import AVFoundation
|
||||
|
||||
// Datenmodell muss byte-für-byte zu dem passen, was
|
||||
// SwiftReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
|
||||
// ReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
|
||||
private enum SharedMediaType: String, Codable {
|
||||
case image, video, text, file, url
|
||||
}
|
||||
@@ -152,17 +152,54 @@ final class ShareViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func copyIntoAppGroup(src: URL) -> URL? {
|
||||
// loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell.
|
||||
private let fileQueue = DispatchQueue(label: "share.files")
|
||||
|
||||
// Ein Ordner pro Share: Gleichnamige Dateien (z. B. bearbeitete Fotos, alle
|
||||
// "FullSizeRender.jpg") überschrieben sich sonst gegenseitig, auch über
|
||||
// zwei Shares hinweg. Die App löscht den Ordner, sobald der Share erledigt ist.
|
||||
private lazy var shareDir: URL? = {
|
||||
guard let container = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
|
||||
return nil
|
||||
}
|
||||
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
|
||||
let dst = container.appendingPathComponent(name)
|
||||
let root = container.appendingPathComponent("share_intent", isDirectory: true)
|
||||
sweepStaleShares(in: root)
|
||||
let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: dst.path) {
|
||||
try FileManager.default.removeItem(at: dst)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
// Shares, deren App-Flow nie abgeschlossen wurde (Prozess beendet o. ä.),
|
||||
// räumt sonst niemand weg — der App-Group-Container wird nie geleert.
|
||||
private func sweepStaleShares(in root: URL) {
|
||||
let fm = FileManager.default
|
||||
guard let entries = try? fm.contentsOfDirectory(
|
||||
at: root, includingPropertiesForKeys: [.creationDateKey]) else { return }
|
||||
let cutoff = Date().addingTimeInterval(-24 * 60 * 60)
|
||||
for entry in entries {
|
||||
let created = (try? entry.resourceValues(forKeys: [.creationDateKey]))?.creationDate
|
||||
if let created, created < cutoff { try? fm.removeItem(at: entry) }
|
||||
}
|
||||
}
|
||||
|
||||
private func copyIntoAppGroup(src: URL) -> URL? {
|
||||
fileQueue.sync { copyIntoShareDir(src: src) }
|
||||
}
|
||||
|
||||
private func copyIntoShareDir(src: URL) -> URL? {
|
||||
guard let dir = shareDir else { return nil }
|
||||
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
|
||||
var dst = dir.appendingPathComponent(name)
|
||||
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares.
|
||||
if FileManager.default.fileExists(atPath: dst.path) {
|
||||
dst = dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)")
|
||||
}
|
||||
do {
|
||||
try FileManager.default.copyItem(at: src, to: dst)
|
||||
return dst
|
||||
} catch {
|
||||
@@ -171,10 +208,9 @@ final class ShareViewController: UIViewController {
|
||||
}
|
||||
|
||||
private func writePng(image: UIImage) -> URL? {
|
||||
guard let container = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId),
|
||||
let data = image.pngData() else { return nil }
|
||||
let dst = container.appendingPathComponent("\(UUID().uuidString).png")
|
||||
guard let data = image.pngData(),
|
||||
let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
|
||||
let dst = dir.appendingPathComponent("\(UUID().uuidString).png")
|
||||
return (try? data.write(to: dst)) != nil ? dst : nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure anchor/slicing logic mirroring `lib/widget_data/widget_data_mapper.dart`
|
||||
/// (resolveDayAnchor / resolveWeekAnchor). Keep both sides in sync — the Dart
|
||||
/// unit tests in test/widget_data/widget_data_mapper_test.dart double as the
|
||||
/// review checklist here:
|
||||
/// resolveDayAnchor(Wed 2026-05-06 10:00) == 2026-05-06 (before cutoff)
|
||||
/// resolveDayAnchor(Wed 2026-05-06 19:00) == 2026-05-07 (after cutoff)
|
||||
/// resolveDayAnchor(Fri 2026-05-08 18:00) == 2026-05-11 (Fri → Mon)
|
||||
/// resolveDayAnchor(Sat 2026-05-09 10:00) == 2026-05-11
|
||||
/// resolveDayAnchor(Sun 2026-05-10 22:00) == 2026-05-11
|
||||
/// resolveWeekAnchor(Tue 2026-05-05 10:00) == 2026-05-04
|
||||
/// resolveWeekAnchor(Sun 2026-05-10 10:00) == 2026-05-11
|
||||
enum TimetableAnchor {
|
||||
/// After 17:00 the user's question shifts from "what's left today" to
|
||||
/// "what's tomorrow", so the day widget rolls forward.
|
||||
static let dayCutoffHour = 17
|
||||
|
||||
static func resolveDayAnchor(_ now: Date, calendar: Calendar = .current) -> Date {
|
||||
var candidate = calendar.startOfDay(for: now)
|
||||
let shiftToTomorrow =
|
||||
calendar.component(.hour, from: now) >= dayCutoffHour || isWeekend(candidate, calendar: calendar)
|
||||
if shiftToTomorrow {
|
||||
candidate = nextDay(candidate, calendar: calendar)
|
||||
}
|
||||
while isWeekend(candidate, calendar: calendar) {
|
||||
candidate = nextDay(candidate, calendar: calendar)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
static func resolveWeekAnchor(_ now: Date, calendar: Calendar = .current) -> Date {
|
||||
let anchor = resolveDayAnchor(now, calendar: calendar)
|
||||
// Swift weekday: 1 = Sunday … 7 = Saturday → distance back to Monday.
|
||||
let daysFromMonday = (calendar.component(.weekday, from: anchor) + 5) % 7
|
||||
let monday = calendar.date(byAdding: .day, value: -daysFromMonday, to: anchor) ?? anchor
|
||||
return calendar.startOfDay(for: monday)
|
||||
}
|
||||
|
||||
/// Future instants at which the rendered anchor can change: each day's
|
||||
/// midnight (rollover) and 17:00 (cutoff), within the horizon. Sorted,
|
||||
/// strictly after `now`.
|
||||
static func boundaryDates(
|
||||
from now: Date,
|
||||
horizonDays: Int = 3,
|
||||
calendar: Calendar = .current
|
||||
) -> [Date] {
|
||||
var result: [Date] = []
|
||||
let today = calendar.startOfDay(for: now)
|
||||
for offset in 0...horizonDays {
|
||||
guard let day = calendar.date(byAdding: .day, value: offset, to: today) else { continue }
|
||||
let midnight = calendar.startOfDay(for: day)
|
||||
if midnight > now { result.append(midnight) }
|
||||
if let cutoff = calendar.date(
|
||||
bySettingHour: dayCutoffHour, minute: 0, second: 0, of: day
|
||||
), cutoff > now {
|
||||
result.append(cutoff)
|
||||
}
|
||||
}
|
||||
return result.sorted()
|
||||
}
|
||||
|
||||
/// Derives a day payload from the 14-day week payload — same shape the
|
||||
/// Dart buildDayData produces, since the week payload runs through the
|
||||
/// identical per-day merge/collision pipeline.
|
||||
static func slice(
|
||||
week: WidgetTimetableData,
|
||||
forDay anchor: Date,
|
||||
calendar: Calendar = .current
|
||||
) -> WidgetTimetableData {
|
||||
let lessons = week.lessons.filter { calendar.isDate($0.start, inSameDayAs: anchor) }
|
||||
// Every v2 week payload carries `days` — the key bump guarantees it.
|
||||
let dayInfo = week.days?.first { calendar.isDate($0.date, inSameDayAs: anchor) }
|
||||
return WidgetTimetableData(
|
||||
fetchedAt: week.fetchedAt,
|
||||
anchorDate: anchor,
|
||||
lessons: lessons,
|
||||
periods: week.periods,
|
||||
isHoliday: dayInfo?.isHoliday ?? false,
|
||||
holidayName: dayInfo?.holidayName,
|
||||
days: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Week payload re-anchored to the week containing/following `anchorDate`.
|
||||
/// The week view filters its five columns off `anchorDate`, so this alone
|
||||
/// performs the Friday-evening/weekend jump to next week.
|
||||
static func retarget(week: WidgetTimetableData, anchorDate: Date) -> WidgetTimetableData {
|
||||
WidgetTimetableData(
|
||||
fetchedAt: week.fetchedAt,
|
||||
anchorDate: anchorDate,
|
||||
lessons: week.lessons,
|
||||
periods: week.periods,
|
||||
isHoliday: week.isHoliday,
|
||||
holidayName: week.holidayName,
|
||||
days: week.days
|
||||
)
|
||||
}
|
||||
|
||||
private static func isWeekend(_ date: Date, calendar: Calendar) -> Bool {
|
||||
let weekday = calendar.component(.weekday, from: date)
|
||||
return weekday == 1 || weekday == 7
|
||||
}
|
||||
|
||||
private static func nextDay(_ date: Date, calendar: Calendar) -> Date {
|
||||
let next = calendar.date(byAdding: .day, value: 1, to: date) ?? date
|
||||
return calendar.startOfDay(for: next)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
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: data.periods,
|
||||
hourHeight: max(
|
||||
MIN_HOUR_HEIGHT,
|
||||
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
|
||||
),
|
||||
showRoom: true,
|
||||
showTeacher: true,
|
||||
showTimeLabels: true
|
||||
periods: periods,
|
||||
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func header(data: WidgetTimetableData) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(dayLabel(for: data.anchorDate))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
HStack(spacing: 6) {
|
||||
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Spacer(minLength: 4)
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
||||
.font(.system(size: 9))
|
||||
.minimumScaleFactor(0.65)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||
.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)
|
||||
}
|
||||
// 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) {
|
||||
gridLines
|
||||
breakBlocks
|
||||
ForEach(lessons.indices, id: \.self) { idx in
|
||||
lessonBlock(lessons[idx])
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
private var timeLabelsColumn: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
@@ -194,39 +114,92 @@ 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))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
} else {
|
||||
if height >= RULER_TIMES_MIN_CELL {
|
||||
VStack(spacing: 0) {
|
||||
Text(formatHm(period.startMinutes))
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Text("\(period.name).")
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
Spacer(minLength: 0)
|
||||
Text(formatHm(period.endMinutes))
|
||||
}
|
||||
.font(.system(size: timeFont))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.vertical, 1)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
.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)
|
||||
}
|
||||
.frame(height: totalHeight, alignment: .topTrailing)
|
||||
}
|
||||
|
||||
/// One day column: grid lines, break bands and the lesson tiles. Shared by
|
||||
/// the day widget and each of the week widget's five columns.
|
||||
struct TimeGridView: View {
|
||||
let lessons: [WidgetLesson]
|
||||
let periods: [WidgetPeriod]
|
||||
let hourHeight: CGFloat
|
||||
var holiday: WidgetDayInfo? = nil
|
||||
var isToday: Bool = false
|
||||
|
||||
@Environment(\.widgetPalette) private var palette
|
||||
|
||||
private var totalHeight: CGFloat {
|
||||
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let metrics = TileMetrics(periods: periods, hourHeight: hourHeight)
|
||||
let innerWidth = geo.size.width - 2 * (TILE_MARGIN_H + TILE_PADDING_H)
|
||||
ZStack(alignment: .top) {
|
||||
if let holiday {
|
||||
holidayColumn(holiday)
|
||||
} else {
|
||||
if isToday {
|
||||
palette.todayTint.frame(height: totalHeight)
|
||||
}
|
||||
gridLines
|
||||
breakBlocks
|
||||
}
|
||||
ForEach(lessons.indices, id: \.self) { idx in
|
||||
lessonBlock(lessons[idx], metrics: metrics, innerWidth: innerWidth)
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: totalHeight, alignment: .top)
|
||||
}
|
||||
.frame(height: totalHeight)
|
||||
}
|
||||
|
||||
private var gridLines: some View {
|
||||
// `.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,103 +212,138 @@ 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))
|
||||
LessonTile(
|
||||
lesson: lesson,
|
||||
height: height,
|
||||
metrics: metrics,
|
||||
layout: TileTextLayout(
|
||||
lesson: lesson,
|
||||
innerHeight: height - 2 * TILE_PADDING_V,
|
||||
innerWidth: innerWidth,
|
||||
metrics: metrics
|
||||
)
|
||||
)
|
||||
.offset(y: top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors the in-app AppointmentTile: title on top, room and teacher
|
||||
/// stacked below, each line shrinking on its own to fit the tile width.
|
||||
private struct LessonTile: View {
|
||||
let lesson: WidgetLesson
|
||||
let height: CGFloat
|
||||
let metrics: TileMetrics
|
||||
let layout: TileTextLayout
|
||||
|
||||
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||
|
||||
private static let cancelledRed =
|
||||
Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255)
|
||||
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: TILE_CORNER_RADIUS)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
titleRow
|
||||
ForEach(Array(layout.body.enumerated()), id: \.offset) { index, line in
|
||||
Text(line)
|
||||
.font(.system(size: metrics.bodyFont))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(MIN_BODY_LINE_FONT / metrics.bodyFont)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: metrics.bodyBox)
|
||||
.padding(.trailing, badgeInset(line: index + 1))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.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)
|
||||
.padding(.horizontal, TILE_PADDING_H)
|
||||
.padding(.vertical, TILE_PADDING_V)
|
||||
.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.
|
||||
.background { tileBackground(shape) }
|
||||
.clipShape(shape)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if let count = lesson.siblingCount, count > 0 {
|
||||
Text("+\(count)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.leading, horizontalPadding)
|
||||
.padding(.bottom, 2)
|
||||
.padding(.trailing, TILE_PADDING_H)
|
||||
.padding(.bottom, TILE_PADDING_V)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
shape.strokeBorder(LessonTile.cancelledRed, lineWidth: 2)
|
||||
GeometryReader { geo in
|
||||
Path { p in
|
||||
p.move(to: .zero)
|
||||
@@ -343,66 +351,79 @@ struct TimeGridView: View {
|
||||
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)
|
||||
.stroke(LessonTile.cancelledRed, lineWidth: 2)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.clipShape(shape)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, showRoom ? 2 : 1)
|
||||
.offset(y: top)
|
||||
.padding(.horizontal, TILE_MARGIN_H)
|
||||
}
|
||||
|
||||
/// Tinted / clear home screens flatten an opaque colour block into a
|
||||
/// solid slab the white text disappears on; a translucent fill with an
|
||||
/// outline keeps tile and text apart in every rendering mode.
|
||||
@ViewBuilder
|
||||
private func tileBackground(_ shape: RoundedRectangle) -> some View {
|
||||
if renderingMode == .fullColor {
|
||||
lesson.tileColor
|
||||
} else {
|
||||
ZStack {
|
||||
shape.fill(Color.white.opacity(0.18))
|
||||
shape.strokeBorder(Color.white.opacity(0.55), lineWidth: 1)
|
||||
}
|
||||
.widgetAccentable()
|
||||
}
|
||||
}
|
||||
|
||||
private func subjectLabel(_ lesson: WidgetLesson) -> String {
|
||||
!lesson.subjectShort.isEmpty
|
||||
? lesson.subjectShort
|
||||
: (lesson.subjectLong ?? "—")
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
private func badgeInset(line: Int) -> CGFloat {
|
||||
layout.badgeLine == line ? BADGE_WIDTH : 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) }
|
||||
/// Fixed-locale formatters cached once — DateFormatter setup is the
|
||||
/// expensive part and the multi-entry timelines render up to ~8 entries per
|
||||
/// reload. Only touched from WidgetKit's archival rendering, so the shared
|
||||
/// instances are safe.
|
||||
enum WidgetDateFormatters {
|
||||
static let shortDate = make("dd.MM.")
|
||||
static let weekdayShort = make("EE")
|
||||
static let weekdayDate = make("EEEE · dd.MM.")
|
||||
static let time = make("HH:mm")
|
||||
static let dateTime = make("dd.MM. HH:mm")
|
||||
|
||||
private static func make(_ format: String) -> DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = format
|
||||
return f
|
||||
}
|
||||
}
|
||||
return result.sorted()
|
||||
}
|
||||
|
||||
func dayLabel(for date: Date) -> String {
|
||||
/// `now` is the timeline entry's date, not `Date()`: WidgetKit archives
|
||||
/// entries ahead of time, so wall-clock reads would be wrong for every
|
||||
/// entry after the first.
|
||||
func dayLabel(for date: Date, relativeTo now: Date) -> String {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let today = cal.startOfDay(for: now)
|
||||
let anchor = cal.startOfDay(for: date)
|
||||
if anchor == today {
|
||||
return "Heute · \(shortDate(date))"
|
||||
@@ -410,35 +431,23 @@ func dayLabel(for date: Date) -> String {
|
||||
if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
|
||||
return "Morgen · \(shortDate(date))"
|
||||
}
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "de_DE")
|
||||
formatter.dateFormat = "EEEE · dd.MM."
|
||||
return formatter.string(from: date)
|
||||
return WidgetDateFormatters.weekdayDate.string(from: date)
|
||||
}
|
||||
|
||||
func shortDate(_ date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = "dd.MM."
|
||||
return f.string(from: date)
|
||||
WidgetDateFormatters.shortDate.string(from: date)
|
||||
}
|
||||
|
||||
func freshnessLabel(for fetchedAt: Date) -> String {
|
||||
func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let today = cal.startOfDay(for: now)
|
||||
let fetchedDay = cal.startOfDay(for: fetchedAt)
|
||||
let timeFmt = DateFormatter()
|
||||
timeFmt.locale = Locale(identifier: "de_DE")
|
||||
timeFmt.dateFormat = "HH:mm"
|
||||
if fetchedDay == today {
|
||||
return timeFmt.string(from: fetchedAt)
|
||||
return WidgetDateFormatters.time.string(from: fetchedAt)
|
||||
}
|
||||
if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
|
||||
fetchedDay == yesterday {
|
||||
return "gestern \(timeFmt.string(from: fetchedAt))"
|
||||
return "gestern \(WidgetDateFormatters.time.string(from: fetchedAt))"
|
||||
}
|
||||
let dateTimeFmt = DateFormatter()
|
||||
dateTimeFmt.locale = Locale(identifier: "de_DE")
|
||||
dateTimeFmt.dateFormat = "dd.MM. HH:mm"
|
||||
return dateTimeFmt.string(from: fetchedAt)
|
||||
return WidgetDateFormatters.dateTime.string(from: fetchedAt)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// Layout constants and rules — must mirror WidgetRenderer.kt on Android,
|
||||
// otherwise the platforms drift apart on the same widget size.
|
||||
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
|
||||
let FALLBACK_SLOT_MINUTES = 45
|
||||
|
||||
/// Trailing periods without lessons are cut off so the tiles get more
|
||||
/// height — but never below this many periods, or a short day would blow up
|
||||
/// to a handful of oversized tiles.
|
||||
let MIN_VISIBLE_PERIODS = 6
|
||||
|
||||
let MIN_HOUR_HEIGHT: CGFloat = 18
|
||||
let MAX_HOUR_HEIGHT: CGFloat = 72
|
||||
let MIN_BLOCK_HEIGHT: CGFloat = 16
|
||||
let LESSON_GAP: CGFloat = 1.5
|
||||
|
||||
let TITLE_ROW_HEIGHT: CGFloat = 20
|
||||
let GRID_MARGIN_TOP: CGFloat = 4
|
||||
let WEEK_HEADER_HEIGHT: CGFloat = 32
|
||||
let RULER_WIDTH: CGFloat = 30
|
||||
|
||||
let TILE_MARGIN_H: CGFloat = 1
|
||||
let TILE_PADDING_H: CGFloat = 4
|
||||
let TILE_PADDING_V: CGFloat = 2
|
||||
let TILE_CORNER_RADIUS: CGFloat = 7
|
||||
|
||||
// In-app tile: 15sp title over 10sp body lines. The widget keeps that ratio
|
||||
// and scales both so title + room + teacher fill one period.
|
||||
let TITLE_TO_BODY_RATIO: CGFloat = 1.5
|
||||
let MAX_BODY_FONT: CGFloat = 10
|
||||
let MIN_BODY_FONT: CGFloat = 6.5
|
||||
let MIN_TITLE_FONT: CGFloat = 6.5
|
||||
let MIN_BODY_LINE_FONT: CGFloat = 5.5
|
||||
let LINE_BOX_PER_FONT: CGFloat = 1.2
|
||||
|
||||
/// If stacking room and teacher would push the body font below this, both
|
||||
/// share one line instead so the text stays readable.
|
||||
let STACKED_BODY_MIN_FONT: CGFloat = 7.5
|
||||
|
||||
/// A title that needs to shrink below this to fit on one line wraps onto a
|
||||
/// second line instead, if the tile has the height for it.
|
||||
let TITLE_WRAP_BELOW_FONT: CGFloat = 8
|
||||
|
||||
// Footprint of the "+N" badge in the tile's bottom trailing corner.
|
||||
let BADGE_WIDTH: CGFloat = 16
|
||||
let BADGE_HEIGHT: CGFloat = 11
|
||||
|
||||
/// Lower than Android's 30dp: a .systemLarge widget only gets ~27pt per
|
||||
/// period, so iOS packs the ruler cell tighter (see PeriodRuler.cell)
|
||||
/// instead of dropping the times.
|
||||
let RULER_TIMES_MIN_CELL: CGFloat = 24
|
||||
let BREAK_ICON_MIN_HEIGHT: CGFloat = 12
|
||||
|
||||
/// Space the holiday column keeps free above and below the rotated name for
|
||||
/// the icon and the "Schulfrei" label.
|
||||
let HOLIDAY_NAME_INSET: CGFloat = 44
|
||||
|
||||
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
|
||||
guard !periods.isEmpty else { return CGFloat(realMin) }
|
||||
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
|
||||
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
|
||||
}
|
||||
let first = periods.first!
|
||||
if realMin < first.startMinutes {
|
||||
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
|
||||
}
|
||||
let last = periods.last!
|
||||
if realMin > last.endMinutes {
|
||||
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
|
||||
}
|
||||
var prev = first
|
||||
for i in 1..<periods.count {
|
||||
let curr = periods[i]
|
||||
if realMin > prev.endMinutes && realMin < curr.startMinutes {
|
||||
let gap = curr.startMinutes - prev.endMinutes
|
||||
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
|
||||
if gap > 0 {
|
||||
return CGFloat(prev.virtualEndMinutes) +
|
||||
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
|
||||
}
|
||||
return CGFloat(curr.virtualStartMinutes)
|
||||
}
|
||||
prev = curr
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func minutesOfDay(_ date: Date) -> Int {
|
||||
let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
|
||||
return (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
||||
}
|
||||
|
||||
func trimTrailingPeriods(_ periods: [WidgetPeriod], lessons: [WidgetLesson]) -> [WidgetPeriod] {
|
||||
guard let latestEnd = lessons.map({ minutesOfDay($0.end) }).max() else { return periods }
|
||||
let lastUsed = periods.lastIndex { $0.startMinutes < latestEnd } ?? -1
|
||||
return Array(periods.prefix(max(lastUsed + 1, MIN_VISIBLE_PERIODS)))
|
||||
}
|
||||
|
||||
func totalVirtualMinutes(_ periods: [WidgetPeriod]) -> Int {
|
||||
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
|
||||
}
|
||||
|
||||
func resolveHourHeight(forGridHeight height: CGFloat, periods: [WidgetPeriod]) -> CGFloat {
|
||||
let virtualHours = max(CGFloat(totalVirtualMinutes(periods)) / 60, 1)
|
||||
return max(MIN_HOUR_HEIGHT, min(MAX_HOUR_HEIGHT, height / virtualHours))
|
||||
}
|
||||
|
||||
/// Period boundaries deduped: adjacent periods share a line, periods on
|
||||
/// either side of a break get their own (bracketing the break block).
|
||||
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
|
||||
var seen = Set<Int>()
|
||||
var result: [Int] = []
|
||||
for p in periods {
|
||||
for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
|
||||
if seen.insert(v).inserted { result.append(v) }
|
||||
}
|
||||
}
|
||||
return result.sorted()
|
||||
}
|
||||
|
||||
struct PeriodBreak: Hashable {
|
||||
let virtualStart: Int
|
||||
let virtualMinutes: Int
|
||||
}
|
||||
|
||||
/// Only big breaks (Hofpause, Mittagspause) survive the mapper's small-break
|
||||
/// collapse as a virtual gap between two periods.
|
||||
func periodBreaks(_ periods: [WidgetPeriod]) -> [PeriodBreak] {
|
||||
guard periods.count > 1 else { return [] }
|
||||
return (0..<periods.count - 1).compactMap { i in
|
||||
let gap = periods[i + 1].virtualStartMinutes - periods[i].virtualEndMinutes
|
||||
return gap > 0
|
||||
? PeriodBreak(virtualStart: periods[i].virtualEndMinutes, virtualMinutes: gap)
|
||||
: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Font and line-box sizes shared by every tile of one widget, derived from
|
||||
/// the height of a single period.
|
||||
struct TileMetrics {
|
||||
let maxBodyLines: Int
|
||||
let bodyFont: CGFloat
|
||||
let titleBox: CGFloat
|
||||
let bodyBox: CGFloat
|
||||
|
||||
init(periods: [WidgetPeriod], hourHeight: CGFloat) {
|
||||
let slotMinutes = periods.first.map { $0.virtualEndMinutes - $0.virtualStartMinutes }
|
||||
?? FALLBACK_SLOT_MINUTES
|
||||
let slotHeight = CGFloat(slotMinutes) * hourHeight / 60
|
||||
let inner = slotHeight - LESSON_GAP - 2 * TILE_PADDING_V
|
||||
func font(lines: CGFloat) -> CGFloat {
|
||||
inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines))
|
||||
}
|
||||
maxBodyLines = font(lines: 2) < STACKED_BODY_MIN_FONT ? 1 : 2
|
||||
bodyFont = max(MIN_BODY_FONT, min(MAX_BODY_FONT, font(lines: CGFloat(maxBodyLines))))
|
||||
titleBox = bodyFont * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT
|
||||
bodyBox = bodyFont * LINE_BOX_PER_FONT
|
||||
}
|
||||
}
|
||||
|
||||
/// Distributes the tile height like the in-app _TileContent: the title comes
|
||||
/// first, then as many body lines as fit.
|
||||
struct TileTextLayout {
|
||||
let title: String
|
||||
let titleBox: CGFloat
|
||||
let titleFont: CGFloat
|
||||
let titleLines: Int
|
||||
let body: [String]
|
||||
/// Set when there is no room for a line below the title: the room moves
|
||||
/// next to it instead.
|
||||
let inline: String?
|
||||
/// Index of the line (0 = title row) that has to stay clear of the "+N"
|
||||
/// badge, or nil when the text ends above it.
|
||||
let badgeLine: Int?
|
||||
|
||||
init(lesson: WidgetLesson, innerHeight: CGFloat, innerWidth: CGFloat, metrics: TileMetrics) {
|
||||
title = !lesson.subjectShort.isEmpty ? lesson.subjectShort : (lesson.subjectLong ?? "—")
|
||||
let candidates = [lesson.room, lesson.teacher ?? lesson.originalTeacher]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
|
||||
let singleTitleBox = min(metrics.titleBox, innerHeight)
|
||||
let capacity = max(
|
||||
0,
|
||||
min(metrics.maxBodyLines, Int((innerHeight - singleTitleBox) / metrics.bodyBox + 0.05))
|
||||
)
|
||||
if capacity >= candidates.count {
|
||||
body = candidates
|
||||
} else if capacity == 1 {
|
||||
// Rather share one line than drop the teacher entirely.
|
||||
body = [candidates.joined(separator: " · ")]
|
||||
} else {
|
||||
body = []
|
||||
}
|
||||
if capacity == 0, let first = candidates.first, !title.contains(first) {
|
||||
inline = first
|
||||
} else {
|
||||
inline = nil
|
||||
}
|
||||
|
||||
let spare = innerHeight - singleTitleBox - CGFloat(body.count) * metrics.bodyBox
|
||||
if spare >= singleTitleBox,
|
||||
let wrappedLine = TileTextLayout.wrappedTitleLineBox(
|
||||
title, innerWidth: innerWidth, metrics: metrics
|
||||
) {
|
||||
titleBox = 2 * wrappedLine
|
||||
titleFont = wrappedLine / LINE_BOX_PER_FONT
|
||||
titleLines = 2
|
||||
} else {
|
||||
titleBox = singleTitleBox
|
||||
titleFont = singleTitleBox / LINE_BOX_PER_FONT
|
||||
titleLines = 1
|
||||
}
|
||||
|
||||
let content = titleBox + CGFloat(body.count) * metrics.bodyBox
|
||||
let hasBadge = (lesson.siblingCount ?? 0) > 0
|
||||
badgeLine = hasBadge && innerHeight - content < BADGE_HEIGHT ? body.count : nil
|
||||
}
|
||||
|
||||
/// Line box for a title wrapped onto two lines, or nil when it should
|
||||
/// stay on one: it fits there at a readable size, or it has no word
|
||||
/// boundary to break at. The box is capped so the longest word still
|
||||
/// fits the tile width.
|
||||
private static func wrappedTitleLineBox(
|
||||
_ title: String,
|
||||
innerWidth: CGFloat,
|
||||
metrics: TileMetrics
|
||||
) -> CGFloat? {
|
||||
func widthPerFontPoint(_ text: String) -> CGFloat {
|
||||
let font = UIFont.systemFont(ofSize: 100, weight: .medium)
|
||||
return (text as NSString).size(withAttributes: [.font: font]).width / 100
|
||||
}
|
||||
if widthPerFontPoint(title) * TITLE_WRAP_BELOW_FONT <= innerWidth { return nil }
|
||||
let words = title
|
||||
.replacingOccurrences(of: "-", with: "- ")
|
||||
.split(separator: " ")
|
||||
.map(String.init)
|
||||
guard words.count > 1, let widest = words.map(widthPerFontPoint).max(), widest > 0 else {
|
||||
return nil
|
||||
}
|
||||
return min(metrics.titleBox, innerWidth / widest * LINE_BOX_PER_FONT)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
/// Parses the `#RRGGBB` tile colour resolved in Dart.
|
||||
init?(widgetHex hex: String?) {
|
||||
guard let hex, hex.count == 7, hex.hasPrefix("#"),
|
||||
let value = UInt32(hex.dropFirst(), radix: 16) else {
|
||||
return nil
|
||||
}
|
||||
self.init(
|
||||
red: Double((value >> 16) & 0xFF) / 255,
|
||||
green: Double((value >> 8) & 0xFF) / 255,
|
||||
blue: Double(value & 0xFF) / 255
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension WidgetLesson {
|
||||
var tileColor: Color {
|
||||
if let resolved = Color(widgetHex: color) { return resolved }
|
||||
// Only reached for payloads written before Dart shipped the resolved
|
||||
// tile colour; mirrors lesson_color.dart.
|
||||
switch status {
|
||||
// No time-based highlight: the widget timeline only ticks every
|
||||
// ~30min, so "current" and "past" would be stale most of the day.
|
||||
case .regular, .past, .ongoing:
|
||||
return Color(red: 153 / 255, green: 51 / 255, blue: 51 / 255)
|
||||
case .cancelled: return .black
|
||||
case .irregular: return Color(red: 143 / 255, green: 25 / 255, blue: 179 / 255)
|
||||
case .teacherChanged: return Color(red: 41 / 255, green: 99 / 255, blue: 155 / 255)
|
||||
case .duty: return Color(red: 0, green: 121 / 255, blue: 107 / 255)
|
||||
case .event:
|
||||
return customColor != nil
|
||||
? Color(red: 1, green: 160 / 255, blue: 0)
|
||||
: Color(red: 46 / 255, green: 125 / 255, blue: 50 / 255)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import WidgetKit
|
||||
struct TimetableWeekView: View {
|
||||
let entry: TimetableEntry
|
||||
@Environment(\.widgetPalette) private var palette
|
||||
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -20,36 +21,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,115 +72,65 @@ 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()
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
||||
.font(.system(size: 9))
|
||||
.minimumScaleFactor(0.65)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||
.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)
|
||||
}
|
||||
ForEach(days.indices, id: \.self) { index in
|
||||
dayHeaderCell(days[index])
|
||||
.frame(maxWidth: .infinity)
|
||||
if offset < 4 { columnDivider }
|
||||
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)
|
||||
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)
|
||||
} 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)
|
||||
Text("\(Calendar.current.component(.day, from: day))")
|
||||
.font(.system(size: 11, weight: isToday ? .bold : .regular))
|
||||
.foregroundStyle(isToday ? todayNumberColor : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 18, height: 18)
|
||||
.background(Circle().fill(isToday ? todayCircleColor : Color.clear))
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
|
||||
}
|
||||
}
|
||||
.frame(height: totalHeight, alignment: .topTrailing)
|
||||
}
|
||||
|
||||
private func column(
|
||||
data: WidgetTimetableData,
|
||||
offset: Int,
|
||||
hourHeight: CGFloat,
|
||||
subjectOnly: Bool
|
||||
) -> some View {
|
||||
let cal = Calendar.current
|
||||
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
|
||||
let lessonsForDay = data.lessons.filter { cal.isDate($0.start, inSameDayAs: day) }
|
||||
return TimeGridView(
|
||||
lessons: lessonsForDay,
|
||||
periods: data.periods,
|
||||
hourHeight: hourHeight,
|
||||
showRoom: !subjectOnly,
|
||||
showTeacher: !subjectOnly,
|
||||
showTimeLabels: false,
|
||||
horizontalPadding: 3
|
||||
)
|
||||
// An opaque accent circle flattens to the same shade as its number on
|
||||
// tinted / clear home screens; keep the circle translucent there.
|
||||
private var todayCircleColor: Color {
|
||||
renderingMode == .fullColor ? palette.accent : Color.white.opacity(0.3)
|
||||
}
|
||||
|
||||
private func weekday(for date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = "EE"
|
||||
return f.string(from: date)
|
||||
private var todayNumberColor: Color {
|
||||
renderingMode == .fullColor ? palette.onAccent : palette.textPrimary
|
||||
}
|
||||
|
||||
private func placeholder(_ message: String) -> some View {
|
||||
|
||||
@@ -41,11 +41,13 @@ struct TimetableDayProvider: TimelineProvider {
|
||||
in context: Context,
|
||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
||||
) {
|
||||
let entry = TimetableEntry.current(variant: .day)
|
||||
let now = Date()
|
||||
// 30 min mirrors the Dart workmanager cadence. iOS treats this as
|
||||
// advisory; the "Stand:" label tells the user when data is stale.
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
// advisory; the boundary entries below keep the rendered day correct
|
||||
// even when no reload is granted, and the "Stand:" label tells the
|
||||
// user when the underlying data is stale.
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||
completion(Timeline(entries: TimetableEntry.dayEntries(now: now), policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +82,9 @@ struct TimetableWeekProvider: TimelineProvider {
|
||||
in context: Context,
|
||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
||||
) {
|
||||
let entry = TimetableEntry.current(variant: .week)
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
let now = Date()
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||
completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +122,58 @@ struct TimetableEntry: TimelineEntry {
|
||||
themeMode: WidgetDataLoader.themeMode()
|
||||
)
|
||||
}
|
||||
|
||||
/// Day timeline derived from the 14-day week payload, so the widget shows
|
||||
/// the right day even when iOS grants no reload for days.
|
||||
static func dayEntries(now: Date) -> [TimetableEntry] {
|
||||
entries(now: now, variant: .day) { week, date in
|
||||
TimetableAnchor.slice(week: week, forDay: TimetableAnchor.resolveDayAnchor(date))
|
||||
}
|
||||
}
|
||||
|
||||
/// Week timeline: re-anchoring performs the Friday-evening/weekend jump
|
||||
/// into next week from cached data, and the midnight entries keep the
|
||||
/// "Stand:" freshness label honest.
|
||||
static func weekEntries(now: Date) -> [TimetableEntry] {
|
||||
entries(now: now, variant: .week) { week, date in
|
||||
TimetableAnchor.retarget(week: week, anchorDate: TimetableAnchor.resolveWeekAnchor(date))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared timeline skeleton: one entry now plus one per anchor boundary
|
||||
/// (midnight rollover, 17:00 cutoff). Boundaries that cannot change the
|
||||
/// render — same anchor and same calendar day for the header labels —
|
||||
/// are dropped.
|
||||
private static func entries(
|
||||
now: Date,
|
||||
variant: TimetableVariant,
|
||||
transform: (WidgetTimetableData, Date) -> WidgetTimetableData
|
||||
) -> [TimetableEntry] {
|
||||
guard WidgetDataLoader.isLoggedIn(), let week = WidgetDataLoader.loadWeek() else {
|
||||
// Logged out, or no v2 week snapshot yet (fresh app update):
|
||||
// fall back to the legacy single-entry payload.
|
||||
return [TimetableEntry.current(variant: variant)]
|
||||
}
|
||||
let theme = WidgetDataLoader.themeMode()
|
||||
let cal = Calendar.current
|
||||
var result: [TimetableEntry] = []
|
||||
for date in [now] + TimetableAnchor.boundaryDates(from: now) {
|
||||
let data = transform(week, date)
|
||||
if let previous = result.last, let previousData = previous.data,
|
||||
cal.isDate(previousData.anchorDate, inSameDayAs: data.anchorDate),
|
||||
cal.isDate(previous.date, inSameDayAs: date) {
|
||||
continue
|
||||
}
|
||||
result.append(TimetableEntry(
|
||||
date: date,
|
||||
variant: variant,
|
||||
data: data,
|
||||
isLoggedIn: true,
|
||||
themeMode: theme
|
||||
))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@@ -147,12 +201,15 @@ private struct WidgetSurface<Content: View>: View {
|
||||
let entry: TimetableEntry
|
||||
@ViewBuilder let content: () -> Content
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||
|
||||
var body: some View {
|
||||
let palette = WidgetPalette.resolve(
|
||||
themeMode: entry.themeMode,
|
||||
colorScheme: colorScheme
|
||||
)
|
||||
// Tinted / clear home screens map every colour to its luminance, so
|
||||
// the light palette's near-black text would vanish — use the dark
|
||||
// palette's light text there regardless of the theme setting.
|
||||
let palette = renderingMode == .fullColor
|
||||
? WidgetPalette.resolve(themeMode: entry.themeMode, colorScheme: colorScheme)
|
||||
: .dark
|
||||
return AnyView(
|
||||
background(content: content(), palette: palette)
|
||||
.environment(\.widgetPalette, palette)
|
||||
|
||||
@@ -10,6 +10,15 @@ enum WidgetLessonStatus: String, Codable {
|
||||
case irregular
|
||||
case teacherChanged
|
||||
case event
|
||||
case duty
|
||||
|
||||
/// Unknown future statuses degrade to `.regular` instead of failing the
|
||||
/// whole payload decode (mirrors WidgetData.kt's fromWire fallback) — a
|
||||
/// single new enum value must never blank the widget to the placeholder.
|
||||
init(from decoder: Decoder) throws {
|
||||
let raw = try decoder.singleValueContainer().decode(String.self)
|
||||
self = WidgetLessonStatus(rawValue: raw) ?? .regular
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetLesson: Codable {
|
||||
@@ -18,10 +27,15 @@ struct WidgetLesson: Codable {
|
||||
let subjectShort: String
|
||||
let subjectLong: String?
|
||||
let room: String?
|
||||
// On teacher accounts this carries the class label ("7a") instead of the
|
||||
// teacher short name (originalTeacher is nil then) — mapped in Dart.
|
||||
let teacher: String?
|
||||
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?
|
||||
}
|
||||
|
||||
@@ -33,6 +47,12 @@ struct WidgetPeriod: Codable {
|
||||
let virtualEndMinutes: Int
|
||||
}
|
||||
|
||||
struct WidgetDayInfo: Codable {
|
||||
let date: Date
|
||||
let isHoliday: Bool
|
||||
let holidayName: String?
|
||||
}
|
||||
|
||||
struct WidgetTimetableData: Codable {
|
||||
let fetchedAt: Date
|
||||
let anchorDate: Date
|
||||
@@ -40,12 +60,17 @@ struct WidgetTimetableData: Codable {
|
||||
let periods: [WidgetPeriod]
|
||||
let isHoliday: Bool
|
||||
let holidayName: String?
|
||||
/// Week payload (v2) only; optional so day payloads keep decoding.
|
||||
let days: [WidgetDayInfo]?
|
||||
}
|
||||
|
||||
/// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a
|
||||
/// schema bump must land in Dart, Kotlin (WidgetRenderer.kt) and here
|
||||
/// together, or the out-of-sync platform silently blanks to the placeholder.
|
||||
enum WidgetDataKey {
|
||||
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
|
||||
static let dayData = "widget_data_day_v1"
|
||||
static let weekData = "widget_data_week_v1"
|
||||
static let weekData = "widget_data_week_v2"
|
||||
static let loggedIn = "widget_data_logged_in_v1"
|
||||
static let themeMode = "widget_setting_theme_mode_v1"
|
||||
}
|
||||
|
||||
@@ -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,17 @@
|
||||
import '../session/session.dart';
|
||||
|
||||
/// Backend identity a feature needs. Modules and settings sections declare
|
||||
/// these; anything whose requirements the session does not meet is hidden.
|
||||
enum AccessRequirement {
|
||||
nextcloud,
|
||||
guardian;
|
||||
|
||||
bool isMetBy(Session? session) => switch (this) {
|
||||
AccessRequirement.nextcloud => session?.nextcloud != null,
|
||||
AccessRequirement.guardian => session is GuardianSession,
|
||||
};
|
||||
}
|
||||
|
||||
extension AccessRequirements on Set<AccessRequirement> {
|
||||
bool areMetBy(Session? session) => every((r) => r.isMetBy(session));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// Account role as reported by MarianumConnect. Only used for display and for
|
||||
/// deriving policies; features gate on capabilities and requirements instead
|
||||
/// of comparing roles.
|
||||
enum UserRole {
|
||||
student,
|
||||
teacher,
|
||||
staff,
|
||||
parent,
|
||||
unknown;
|
||||
|
||||
/// Unknown or missing values map to [unknown] so a new server-side role
|
||||
/// never breaks older app versions.
|
||||
static UserRole parse(String? wire) => switch (wire) {
|
||||
'STUDENT' => UserRole.student,
|
||||
'TEACHER' => UserRole.teacher,
|
||||
'STAFF' => UserRole.staff,
|
||||
'PARENT' => UserRole.parent,
|
||||
_ => UserRole.unknown,
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
class ApiRequest {}
|
||||
@@ -0,0 +1,219 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../session/session_manager.dart';
|
||||
import '../utils/directory_size.dart';
|
||||
|
||||
/// One cached response: the raw JSON payload and when it was stored.
|
||||
class CacheEntry {
|
||||
final String json;
|
||||
final int lastUpdate;
|
||||
|
||||
const CacheEntry({required this.json, required this.lastUpdate});
|
||||
}
|
||||
|
||||
/// File-per-key store behind [RequestCache] and friends.
|
||||
///
|
||||
/// Replaces `localstore` for response caching: localstore reads and decodes
|
||||
/// the entire collection synchronously on first access, keeps every document
|
||||
/// in memory for the whole session and JSON-encodes payloads a second time.
|
||||
/// Here every access is async and touches exactly one file, and the payload is
|
||||
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
|
||||
///
|
||||
/// Every account has its own directory; all access goes to the one of the
|
||||
/// active account, in every isolate.
|
||||
class CacheStore {
|
||||
CacheStore._();
|
||||
|
||||
static final CacheStore instance = CacheStore._();
|
||||
|
||||
/// Payloads above this size are decoded on a background isolate.
|
||||
static const int isolateDecodeThreshold = 64 * 1024;
|
||||
|
||||
static const _directoryPrefix = 'request_cache';
|
||||
|
||||
final Map<String, Future<Directory>> _dirs = {};
|
||||
final Map<String, Future<void>> _writes = {};
|
||||
|
||||
/// Directory name for an account's storage [namespace]; null while no
|
||||
/// account is active. The account from before multi-account support
|
||||
/// (namespace '') keeps the original directory.
|
||||
@visibleForTesting
|
||||
static String directoryName(String? namespace) => switch (namespace) {
|
||||
null => '$_directoryPrefix-signed-out',
|
||||
'' => _directoryPrefix,
|
||||
_ => '$_directoryPrefix-$namespace',
|
||||
};
|
||||
|
||||
Future<Directory> _directory() {
|
||||
final name = directoryName(SessionManager().activeAccount?.namespace);
|
||||
return _dirs[name] ??= () async {
|
||||
final base = await getApplicationCacheDirectory();
|
||||
final dir = Directory('${base.path}/$name');
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
}();
|
||||
}
|
||||
|
||||
Future<File> _file(String key) async =>
|
||||
File('${(await _directory()).path}/${_safeName(key)}');
|
||||
|
||||
/// Keys are internal ids (`nc-chat-<token>`, `wd-folder-<md5>`); only path
|
||||
/// separators would be a problem in a file name.
|
||||
static String _safeName(String key) => key.replaceAll(RegExp(r'[/\\]'), '_');
|
||||
|
||||
Future<CacheEntry?> read(String key) async {
|
||||
try {
|
||||
final file = await _file(key);
|
||||
await _writes[file.path];
|
||||
return parse(await file.readAsString());
|
||||
} on Object {
|
||||
// Missing (PathNotFoundException) or unreadable: a cache miss.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// All entries whose key starts with [prefix], read concurrently.
|
||||
Future<Map<String, CacheEntry>> readAll({String prefix = ''}) async {
|
||||
final keys = await this.keys(prefix: prefix);
|
||||
final entries = await Future.wait(keys.map(read));
|
||||
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
|
||||
}
|
||||
|
||||
Future<void> write(String key, String json) async {
|
||||
// Resolved up front: a queued write must not follow an account switch
|
||||
// into the next account's directory.
|
||||
final file = await _file(key);
|
||||
final path = file.path;
|
||||
final previous = _writes[path] ?? Future<void>.value();
|
||||
late final Future<void> current;
|
||||
current = previous.then((_) => _write(file, json)).whenComplete(() {
|
||||
if (identical(_writes[path], current)) _writes.remove(path);
|
||||
});
|
||||
return _writes[path] = current;
|
||||
}
|
||||
|
||||
Future<void> _write(File file, String json) async {
|
||||
try {
|
||||
// Write-then-rename so a reader (or the widget background isolate)
|
||||
// never sees a half-written file.
|
||||
final tmp = File('${file.path}.tmp');
|
||||
await tmp.writeAsString(
|
||||
'${DateTime.now().millisecondsSinceEpoch}\n$json',
|
||||
flush: true,
|
||||
);
|
||||
await tmp.rename(file.path);
|
||||
} on Object catch (e) {
|
||||
debugPrint('CacheStore.write(${file.path}) failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(String key) async {
|
||||
try {
|
||||
final file = await _file(key);
|
||||
await _writes[file.path];
|
||||
await file.delete();
|
||||
} on Object {
|
||||
// A missing or locked file is as good as deleted for a cache.
|
||||
}
|
||||
}
|
||||
|
||||
/// Empties the active account's cache.
|
||||
Future<void> clear() async {
|
||||
try {
|
||||
final dir = await _directory();
|
||||
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||
} on Object catch (e) {
|
||||
debugPrint('CacheStore.clear failed: $e');
|
||||
}
|
||||
_dirs.clear();
|
||||
}
|
||||
|
||||
/// Removes the cache of the account with [namespace], active or not.
|
||||
Future<void> deleteNamespace(String namespace) async {
|
||||
final name = directoryName(namespace);
|
||||
try {
|
||||
final base = await getApplicationCacheDirectory();
|
||||
final dir = Directory('${base.path}/$name');
|
||||
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||
} on Object catch (e) {
|
||||
debugPrint('CacheStore.deleteNamespace failed: $e');
|
||||
}
|
||||
_dirs.removeWhere((key, _) => key == name);
|
||||
}
|
||||
|
||||
/// All stored keys, optionally filtered by [prefix].
|
||||
Future<List<String>> keys({String prefix = ''}) async {
|
||||
try {
|
||||
final dir = await _directory();
|
||||
return [
|
||||
await for (final entity in dir.list())
|
||||
if (entity is File && !entity.path.endsWith('.tmp'))
|
||||
if (entity.uri.pathSegments.last case final name
|
||||
when name.startsWith(prefix))
|
||||
name,
|
||||
];
|
||||
} on Object {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes entries not written for [maxAge] from every account's cache,
|
||||
/// judged by file mtime so nothing has to be read or decoded.
|
||||
Future<void> deleteOlderThan(Duration maxAge) async {
|
||||
try {
|
||||
final base = await getApplicationCacheDirectory();
|
||||
final cutoff = DateTime.now().subtract(maxAge);
|
||||
await for (final dir in base.list()) {
|
||||
if (dir is! Directory) continue;
|
||||
final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
|
||||
if (!name.startsWith(_directoryPrefix)) continue;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is! File) continue;
|
||||
// ignore: avoid_slow_async_io
|
||||
final modified = (await entity.stat()).modified;
|
||||
if (modified.isBefore(cutoff)) await entity.delete();
|
||||
}
|
||||
}
|
||||
} on Object catch (e) {
|
||||
debugPrint('CacheStore.deleteOlderThan failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Total size of all entries in bytes.
|
||||
Future<int> totalSize() async => directorySize(await _directory());
|
||||
|
||||
/// Parses the stored file format; public for tests.
|
||||
static CacheEntry? parse(String raw) {
|
||||
final newline = raw.indexOf('\n');
|
||||
if (newline < 0) return null;
|
||||
final lastUpdate = int.tryParse(raw.substring(0, newline));
|
||||
if (lastUpdate == null) return null;
|
||||
return CacheEntry(json: raw.substring(newline + 1), lastUpdate: lastUpdate);
|
||||
}
|
||||
|
||||
/// Decodes a cached payload, off the UI isolate when it is large.
|
||||
static Future<Map<String, dynamic>> decode(String json) =>
|
||||
json.length > isolateDecodeThreshold
|
||||
? compute(_decodeMap, json)
|
||||
: Future.value(_decodeMap(json));
|
||||
|
||||
static Map<String, dynamic> _decodeMap(String json) =>
|
||||
jsonDecode(json) as Map<String, dynamic>;
|
||||
|
||||
/// Deletes the directory the old localstore-based cache lived in. It is
|
||||
/// never read again and would otherwise linger with up to 200 days of data.
|
||||
static Future<void> deleteLegacyLocalstoreCache() async {
|
||||
try {
|
||||
final docs = await getApplicationDocumentsDirectory();
|
||||
final legacy = Directory('${docs.path}/MarianumMobile');
|
||||
if (legacy.existsSync()) await legacy.delete(recursive: true);
|
||||
} on Object catch (e) {
|
||||
debugPrint('Legacy cache cleanup failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../../marianumconnect/queries/absence/absence_prefill_response.dart';
|
||||
|
||||
/// Demo fixtures for the absence-report form: the class dropdown and the
|
||||
/// identity/phone prefill. Typed (compile-checked) so a field rename can't
|
||||
/// silently drift the demo shape away from what the queries parse.
|
||||
class DemoAbsence {
|
||||
const DemoAbsence._();
|
||||
|
||||
static List<String> classes() => const ['5a', '6b', '7c', '9d', '10a', 'Q1'];
|
||||
|
||||
static AbsencePrefillResponse prefill() => AbsencePrefillResponse(
|
||||
firstName: 'Max',
|
||||
lastName: 'Mustermann',
|
||||
className: '10a',
|
||||
phone: '0123 456789',
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,43 @@
|
||||
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
|
||||
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
|
||||
import '../../marianumconnect/queries/get_capabilities/guardian_child.dart';
|
||||
import '../demo_persona.dart';
|
||||
|
||||
/// Demo fixtures for the mobile capability flags — everything granted so the
|
||||
/// demo persona sees every feature (incl. push) as available.
|
||||
/// demo persona sees every feature (incl. push) as available and the timetable
|
||||
/// scroll range stays unlimited (null day counts).
|
||||
class DemoCapabilities {
|
||||
const DemoCapabilities._();
|
||||
|
||||
static CapabilitiesState state() => const CapabilitiesState(
|
||||
viewForeignTimetables: true,
|
||||
pushNotifications: true,
|
||||
timetablePastDays: null,
|
||||
timetableFutureDays: null,
|
||||
// Die Demo-Persona ist explizit Schüler — null hieße "Backend kennt das
|
||||
// Feld nicht" (siehe CapabilitiesResponse.userType).
|
||||
userType: 'STUDENT',
|
||||
loaded: true,
|
||||
);
|
||||
|
||||
/// Guardian persona with two children, so the child switcher is visible.
|
||||
static CapabilitiesState guardianState() => const CapabilitiesState(
|
||||
pushNotifications: true,
|
||||
userType: 'PARENT',
|
||||
children: [
|
||||
GuardianChild(
|
||||
id: 'demo-child-1',
|
||||
firstName: DemoPersona.studentFirstName,
|
||||
lastName: 'Hoffmann',
|
||||
className: DemoPersona.className,
|
||||
),
|
||||
GuardianChild(
|
||||
id: 'demo-child-2',
|
||||
firstName: 'Jonas',
|
||||
lastName: 'Hoffmann',
|
||||
className: '6a',
|
||||
),
|
||||
],
|
||||
loaded: true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import '../../marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||
import '../demo_persona.dart';
|
||||
|
||||
/// Demo inbox of the guardian persona: one letter per variant (information,
|
||||
/// acknowledgement, choice, signature, answered, expired, thread, attachment).
|
||||
/// Child ids match [DemoCapabilities.guardianState].
|
||||
class DemoParentLetters {
|
||||
const DemoParentLetters._();
|
||||
|
||||
static const _lena = 'demo-child-1';
|
||||
static const _jonas = 'demo-child-2';
|
||||
|
||||
static final Set<String> _readInSession = {};
|
||||
|
||||
static void markRead(String letterId) => _readInSession.add(letterId);
|
||||
|
||||
static ParentLetterListResponse list() {
|
||||
final items = [for (final letter in _letters()) letter.summary];
|
||||
return ParentLetterListResponse(
|
||||
items: items,
|
||||
unreadCount: items.where((letter) => !letter.read).length,
|
||||
openCount: items
|
||||
.where((letter) => letter.status == ParentLetterStatus.open)
|
||||
.length,
|
||||
);
|
||||
}
|
||||
|
||||
static ParentLetterDetail detail(String letterId) {
|
||||
final letters = _letters();
|
||||
return letters.firstWhere(
|
||||
(letter) => letter.summary.id == letterId,
|
||||
orElse: () => letters.first,
|
||||
);
|
||||
}
|
||||
|
||||
static ParentLetterPerson _teacher(int index) =>
|
||||
ParentLetterPerson(displayName: DemoPersona.teachers[index].name);
|
||||
|
||||
static const _participation = ParentLetterField(
|
||||
id: 'participation',
|
||||
type: ParentLetterFieldType.singleChoice,
|
||||
label: 'Nimmt Ihr Kind teil?',
|
||||
isRequired: true,
|
||||
options: [
|
||||
ParentLetterOption(id: 'yes', label: 'Ja'),
|
||||
ParentLetterOption(id: 'no', label: 'Nein'),
|
||||
],
|
||||
);
|
||||
|
||||
static List<ParentLetterDetail> _letters() {
|
||||
final now = DateTime.now();
|
||||
ParentLetterDetail letter({
|
||||
required String id,
|
||||
required String subject,
|
||||
required ParentLetterPerson sender,
|
||||
required Duration age,
|
||||
required String body,
|
||||
required List<String> childIds,
|
||||
bool read = true,
|
||||
ParentLetterStatus status = ParentLetterStatus.info,
|
||||
ParentLetterRequest? request,
|
||||
List<ParentLetterChildState> children = const [],
|
||||
List<ParentLetterAttachment> attachments = const [],
|
||||
ParentLetterThread thread = const ParentLetterThread(enabled: true),
|
||||
}) => ParentLetterDetail(
|
||||
summary: ParentLetterSummary(
|
||||
id: id,
|
||||
subject: subject,
|
||||
preview: body.replaceAll('\n', ' ').trim(),
|
||||
sender: sender,
|
||||
sentAt: now.subtract(age),
|
||||
read: read || _readInSession.contains(id),
|
||||
childIds: childIds,
|
||||
attachmentCount: attachments.length,
|
||||
status: status,
|
||||
deadline: request?.deadline,
|
||||
),
|
||||
content: ParentLetterContent(
|
||||
body: body,
|
||||
attachments: attachments,
|
||||
request: request,
|
||||
children: children,
|
||||
thread: thread,
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
letter(
|
||||
id: 'demo-letter-hike',
|
||||
subject: 'Wandertag der ${DemoPersona.className}',
|
||||
sender: _teacher(0),
|
||||
age: const Duration(hours: 2),
|
||||
read: false,
|
||||
childIds: const [_lena],
|
||||
status: ParentLetterStatus.open,
|
||||
body:
|
||||
'Liebe Eltern,\n\nam Freitag in zwei Wochen findet unser Wandertag '
|
||||
'statt. Wir fahren mit dem Bus in die Rhön und sind gegen 16 Uhr '
|
||||
'zurück. Alle Einzelheiten finden Sie im angehängten Schreiben.\n\n'
|
||||
'Bitte geben Sie uns eine verbindliche Rückmeldung.\n\n'
|
||||
'Viele Grüße\n${DemoPersona.teachers[0].name}',
|
||||
attachments: const [
|
||||
ParentLetterAttachment(
|
||||
id: 'demo-attachment-hike',
|
||||
fileName: 'Wandertag.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
size: 48211,
|
||||
),
|
||||
],
|
||||
request: ParentLetterRequest(
|
||||
fields: const [_participation],
|
||||
signatureRequired: true,
|
||||
deadline: now.add(const Duration(days: 10)),
|
||||
),
|
||||
children: const [
|
||||
ParentLetterChildState(
|
||||
childId: _lena,
|
||||
status: ParentLetterStatus.open,
|
||||
editable: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
letter(
|
||||
id: 'demo-letter-parents-evening',
|
||||
subject: 'Einladung zum Elternabend der 6a',
|
||||
sender: _teacher(1),
|
||||
age: const Duration(days: 1),
|
||||
read: false,
|
||||
childIds: const [_jonas],
|
||||
status: ParentLetterStatus.open,
|
||||
body:
|
||||
'Liebe Eltern,\n\nhiermit lade ich Sie herzlich zum ersten '
|
||||
'Elternabend des Schuljahres ein. Wir treffen uns am kommenden '
|
||||
'Dienstag um 19 Uhr in Raum ${DemoPersona.rooms[0]}.\n\n'
|
||||
'Bitte bestätigen Sie kurz den Erhalt dieser Einladung.',
|
||||
request: const ParentLetterRequest(),
|
||||
children: const [
|
||||
ParentLetterChildState(
|
||||
childId: _jonas,
|
||||
status: ParentLetterStatus.open,
|
||||
editable: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
letter(
|
||||
id: 'demo-letter-school-festival',
|
||||
subject: 'Schulfest: Wer kommt mit?',
|
||||
sender: _teacher(5),
|
||||
age: const Duration(days: 3),
|
||||
childIds: const [_lena, _jonas],
|
||||
status: ParentLetterStatus.open,
|
||||
body:
|
||||
'Liebe Eltern,\n\nfür die Planung unseres Schulfestes möchten wir '
|
||||
'wissen, welche Kinder teilnehmen. Die Angabe können Sie bis zum '
|
||||
'Ablauf der Frist noch ändern.\n\nWeitere Informationen: '
|
||||
'https://www.marianum-fulda.de',
|
||||
request: ParentLetterRequest(
|
||||
fields: const [_participation],
|
||||
deadline: now.add(const Duration(days: 5)),
|
||||
),
|
||||
children: [
|
||||
ParentLetterChildState(
|
||||
childId: _lena,
|
||||
status: ParentLetterStatus.done,
|
||||
editable: true,
|
||||
response: ParentLetterResponse(
|
||||
respondedAt: now.subtract(const Duration(days: 2)),
|
||||
respondedBy: const ParentLetterPerson(self: true),
|
||||
answers: const [
|
||||
ParentLetterAnswer(
|
||||
fieldId: 'participation',
|
||||
optionIds: ['yes'],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const ParentLetterChildState(
|
||||
childId: _jonas,
|
||||
status: ParentLetterStatus.open,
|
||||
editable: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
letter(
|
||||
id: 'demo-letter-reports',
|
||||
subject: 'Zeugnisausgabe und Unterrichtsschluss',
|
||||
sender: _teacher(3),
|
||||
age: const Duration(days: 7),
|
||||
childIds: const [_lena, _jonas],
|
||||
body:
|
||||
'Liebe Eltern,\n\nam letzten Schultag vor den Ferien endet der '
|
||||
'Unterricht nach der dritten Stunde mit der Zeugnisausgabe. Die '
|
||||
'Busse fahren entsprechend früher.',
|
||||
thread: ParentLetterThread(
|
||||
enabled: true,
|
||||
messages: [
|
||||
ParentLetterThreadMessage(
|
||||
id: 'demo-thread-1',
|
||||
author: const ParentLetterPerson(self: true),
|
||||
body: 'Gilt das auch für die Nachmittagsbetreuung?',
|
||||
sentAt: now.subtract(const Duration(days: 6, hours: 20)),
|
||||
),
|
||||
ParentLetterThreadMessage(
|
||||
id: 'demo-thread-2',
|
||||
author: _teacher(3),
|
||||
body:
|
||||
'Ja, die Betreuung entfällt an diesem Tag ebenfalls. Bei '
|
||||
'Bedarf melden Sie sich bitte im Sekretariat.',
|
||||
sentAt: now.subtract(const Duration(days: 6, hours: 2)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
letter(
|
||||
id: 'demo-letter-swimming',
|
||||
subject: 'Einverständnis Schwimmunterricht',
|
||||
sender: _teacher(6),
|
||||
age: const Duration(days: 21),
|
||||
childIds: const [_jonas],
|
||||
status: ParentLetterStatus.done,
|
||||
body:
|
||||
'Liebe Eltern,\n\nim zweiten Halbjahr findet der Sportunterricht '
|
||||
'der 6a im Hallenbad statt. Dafür benötigen wir Ihr '
|
||||
'Einverständnis.',
|
||||
request: const ParentLetterRequest(
|
||||
fields: [_participation],
|
||||
signatureRequired: true,
|
||||
),
|
||||
children: [
|
||||
ParentLetterChildState(
|
||||
childId: _jonas,
|
||||
status: ParentLetterStatus.done,
|
||||
response: ParentLetterResponse(
|
||||
respondedAt: now.subtract(const Duration(days: 20)),
|
||||
respondedBy: const ParentLetterPerson(displayName: 'M. Hoffmann'),
|
||||
answers: const [
|
||||
ParentLetterAnswer(
|
||||
fieldId: 'participation',
|
||||
optionIds: ['yes'],
|
||||
),
|
||||
],
|
||||
signed: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
thread: const ParentLetterThread(),
|
||||
),
|
||||
letter(
|
||||
id: 'demo-letter-ski-trip',
|
||||
subject: 'Anmeldung zur Skifreizeit',
|
||||
sender: _teacher(6),
|
||||
age: const Duration(days: 35),
|
||||
childIds: const [_lena],
|
||||
status: ParentLetterStatus.expired,
|
||||
body:
|
||||
'Liebe Eltern,\n\ndie Anmeldung zur Skifreizeit der Jahrgangsstufe '
|
||||
'10 ist ab sofort möglich. Die Plätze sind begrenzt.',
|
||||
request: ParentLetterRequest(
|
||||
fields: const [_participation],
|
||||
signatureRequired: true,
|
||||
deadline: now.subtract(const Duration(days: 14)),
|
||||
),
|
||||
children: const [
|
||||
ParentLetterChildState(
|
||||
childId: _lena,
|
||||
status: ParentLetterStatus.expired,
|
||||
),
|
||||
],
|
||||
thread: const ParentLetterThread(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../marianumcloud/talk/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,
|
||||
@@ -324,6 +325,22 @@ class DemoTalk {
|
||||
message: 'Gute Idee, ich sammle schon mal Vorschläge.',
|
||||
reactions: {'👍': 3},
|
||||
),
|
||||
_msg(
|
||||
id: base + 4,
|
||||
token: token,
|
||||
actor: 'ben',
|
||||
display: 'Ben',
|
||||
ago: const Duration(hours: 2),
|
||||
message:
|
||||
'## Ablauf Sommerfest\n'
|
||||
'Ein paar **Punkte** für Mittwoch:\n\n'
|
||||
'- Stände aufbauen ab *14 Uhr*\n'
|
||||
'- [x] Getränke bestellt\n'
|
||||
'- [ ] Musik organisieren\n\n'
|
||||
'> Bitte bringt Ideen mit!\n\n'
|
||||
'Mehr Infos auf der [SV-Seite](https://marianum-fulda.de).',
|
||||
markdown: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -346,14 +363,16 @@ class DemoTalk {
|
||||
required Duration ago,
|
||||
required String message,
|
||||
Map<String, int>? reactions,
|
||||
bool markdown = false,
|
||||
}) => _msg(
|
||||
id: id,
|
||||
token: token,
|
||||
actor: AccountData().getUsername(),
|
||||
actor: SessionManager().requireNextcloud().username,
|
||||
display: DemoPersona.studentName,
|
||||
ago: ago,
|
||||
message: message,
|
||||
reactions: reactions,
|
||||
markdown: markdown,
|
||||
);
|
||||
|
||||
static GetChatResponseObject _msg({
|
||||
@@ -364,6 +383,7 @@ class DemoTalk {
|
||||
required Duration ago,
|
||||
required String message,
|
||||
Map<String, int>? reactions,
|
||||
bool markdown = false,
|
||||
}) => GetChatResponseObject(
|
||||
id,
|
||||
token,
|
||||
@@ -380,6 +400,7 @@ class DemoTalk {
|
||||
reactions,
|
||||
null,
|
||||
null,
|
||||
markdown,
|
||||
);
|
||||
|
||||
static GetRoomResponseObject _room({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'data/demo_absence.dart';
|
||||
import 'data/demo_breaker.dart';
|
||||
import 'data/demo_holidays.dart';
|
||||
import 'data/demo_timetable.dart';
|
||||
@@ -39,6 +40,10 @@ class DemoMarianumConnect {
|
||||
.toList();
|
||||
case 'breaker':
|
||||
return DemoBreaker.none().toJson();
|
||||
case 'absence/classes':
|
||||
return DemoAbsence.classes();
|
||||
case 'absence/prefill':
|
||||
return DemoAbsence.prefill().toJson();
|
||||
case 'timetable/elements/teachers':
|
||||
case 'timetable/elements/students':
|
||||
case 'timetable/elements/classes':
|
||||
|
||||
@@ -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 {
|
||||
@@ -22,6 +22,14 @@ class DemoMode {
|
||||
static bool matches(String username) =>
|
||||
username.trim().toLowerCase().startsWith(usernamePrefix);
|
||||
|
||||
/// Guardian logins are passwordless, so reviewers need a fixed address that
|
||||
/// skips the mail round-trip. Deliberately not the `demo@` prefix, which a
|
||||
/// real e-mail address could start with.
|
||||
static const String guardianEmail = 'demo-eltern@marianum-fulda.de';
|
||||
|
||||
static bool matchesGuardian(String email) =>
|
||||
email.trim().toLowerCase() == guardianEmail;
|
||||
|
||||
/// True while the active session is a demo session.
|
||||
static bool get active => AccountData().isDemo;
|
||||
static bool get active => SessionManager().isDemo;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/// A backend-independent emergency notice loaded from a foreign server URL —
|
||||
/// frontmatter (control fields) plus a free Markdown body, so it stays
|
||||
/// hand-writable in an outage. See [parse] for the format.
|
||||
class EmergencyNotice {
|
||||
/// `false` renders full-screen and blocks back/barrier taps.
|
||||
final bool dismissible;
|
||||
final String? title;
|
||||
final String body;
|
||||
|
||||
const EmergencyNotice({
|
||||
required this.dismissible,
|
||||
required this.title,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
/// Parses the raw file, or returns `null` when there is nothing to show.
|
||||
/// Never throws — malformed input yields `null` so a broken file can't break
|
||||
/// the app.
|
||||
///
|
||||
/// ```
|
||||
/// ---
|
||||
/// active: true # required truthy, else null; # lines are comments
|
||||
/// dismissible: true # default true
|
||||
/// title: Störung # optional
|
||||
/// ---
|
||||
/// Free **markdown** body (everything after the closing ---).
|
||||
/// ```
|
||||
static EmergencyNotice? parse(String raw) {
|
||||
final lines = raw
|
||||
.replaceAll('\r\n', '\n')
|
||||
.replaceAll('\r', '\n')
|
||||
.split('\n');
|
||||
|
||||
var i = 0;
|
||||
while (i < lines.length && lines[i].trim().isEmpty) {
|
||||
i++;
|
||||
}
|
||||
if (i >= lines.length || lines[i].trim() != '---') return null;
|
||||
final openIndex = i;
|
||||
|
||||
var closeIndex = -1;
|
||||
for (var j = openIndex + 1; j < lines.length; j++) {
|
||||
if (lines[j].trim() == '---') {
|
||||
closeIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (closeIndex == -1) return null;
|
||||
|
||||
final meta = <String, String>{};
|
||||
for (var j = openIndex + 1; j < closeIndex; j++) {
|
||||
final line = lines[j].trim();
|
||||
if (line.isEmpty || line.startsWith('#')) continue;
|
||||
final sep = line.indexOf(':');
|
||||
if (sep <= 0) continue;
|
||||
final key = line.substring(0, sep).trim().toLowerCase();
|
||||
final value = line.substring(sep + 1).trim();
|
||||
meta[key] = value;
|
||||
}
|
||||
|
||||
if (_parseBool(meta['active']) != true) return null;
|
||||
|
||||
final body = lines.sublist(closeIndex + 1).join('\n').trim();
|
||||
if (body.isEmpty) return null;
|
||||
|
||||
final title = meta['title'];
|
||||
return EmergencyNotice(
|
||||
dismissible: _parseBool(meta['dismissible']) ?? true,
|
||||
title: (title == null || title.isEmpty) ? null : title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
static bool? _parseBool(String? value) {
|
||||
switch (value?.trim().toLowerCase()) {
|
||||
case 'true':
|
||||
case 'yes':
|
||||
case '1':
|
||||
case 'on':
|
||||
return true;
|
||||
case 'false':
|
||||
case 'no':
|
||||
case '0':
|
||||
case 'off':
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'emergency_notice.dart';
|
||||
|
||||
/// Loads the emergency notice from a foreign URL over a standalone [Dio] — no
|
||||
/// MarianumConnect interceptors/base URL/auth, so it survives a backend outage.
|
||||
/// Never throws: any failure yields `null` (nothing shown).
|
||||
///
|
||||
/// A [cacheTtl] in-memory throttle keeps rapid resumes from hammering the host;
|
||||
/// it lives only for the process, so a cold start always fetches fresh.
|
||||
class EmergencyNoticeClient {
|
||||
EmergencyNoticeClient();
|
||||
|
||||
static const Duration cacheTtl = Duration(minutes: 1);
|
||||
|
||||
EmergencyNotice? _cached;
|
||||
String? _cachedUrl;
|
||||
DateTime? _cachedAt;
|
||||
|
||||
Future<EmergencyNotice?> fetch(String url) async {
|
||||
final cachedAt = _cachedAt;
|
||||
if (cachedAt != null &&
|
||||
_cachedUrl == url &&
|
||||
DateTime.now().difference(cachedAt) < cacheTtl) {
|
||||
return _cached;
|
||||
}
|
||||
|
||||
EmergencyNotice? result;
|
||||
try {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
sendTimeout: const Duration(seconds: 5),
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
);
|
||||
final response = await dio.get<String>(url);
|
||||
final raw = response.data;
|
||||
result = (raw == null || raw.isEmpty) ? null : EmergencyNotice.parse(raw);
|
||||
} catch (_) {
|
||||
result = null;
|
||||
}
|
||||
|
||||
// Cache failures too, so a down server isn't retried on every resume.
|
||||
_cached = result;
|
||||
_cachedUrl = url;
|
||||
_cachedAt = DateTime.now();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ 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';
|
||||
@@ -59,9 +61,10 @@ AppException? _dioToAppException(DioException error) {
|
||||
/// status plus a trimmed body preview (same format as the Talk API errors).
|
||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||
final status = error.statusCode;
|
||||
final body = error.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
||||
final detail = body.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
||||
final preview = previewBody(error.body);
|
||||
final detail = preview.isEmpty
|
||||
? 'HTTP $status'
|
||||
: 'HTTP $status body=$preview';
|
||||
switch (status) {
|
||||
case 401:
|
||||
return AuthException.unauthorized(technicalDetails: detail);
|
||||
@@ -86,6 +89,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
||||
if (error is AppException) return error.userMessage;
|
||||
|
||||
if (error is 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 +142,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;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'app_exception.dart';
|
||||
|
||||
/// The user denied a system permission the action needs (camera, photos).
|
||||
/// Retrying won't help until the permission is granted in the system settings.
|
||||
class PermissionException extends AppException {
|
||||
const PermissionException({
|
||||
required super.userMessage,
|
||||
super.technicalDetails,
|
||||
}) : super(allowRetry: false);
|
||||
|
||||
factory PermissionException.camera({String? technicalDetails}) =>
|
||||
PermissionException(
|
||||
userMessage:
|
||||
'Kein Zugriff auf die Kamera. Bitte erlaube der App den '
|
||||
'Kamerazugriff in den Einstellungen deines Geräts.',
|
||||
technicalDetails: technicalDetails,
|
||||
);
|
||||
|
||||
factory PermissionException.photos({String? technicalDetails}) =>
|
||||
PermissionException(
|
||||
userMessage:
|
||||
'Kein Zugriff auf deine Fotos. Bitte erlaube der App den '
|
||||
'Fotozugriff in den Einstellungen deines Geräts.',
|
||||
technicalDetails: technicalDetails,
|
||||
);
|
||||
|
||||
/// Maps the `*_access_denied` codes thrown by `image_picker`; `null` for any
|
||||
/// other platform error.
|
||||
static PermissionException? fromPlatform(PlatformException error) =>
|
||||
switch (error.code) {
|
||||
'camera_access_denied' => PermissionException.camera(
|
||||
technicalDetails: error.message,
|
||||
),
|
||||
'photo_access_denied' => PermissionException.photos(
|
||||
technicalDetails: error.message,
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'app_exception.dart';
|
||||
|
||||
/// A request finished after the account it was started for signed out. Its
|
||||
/// result must not reach the state or cache of whoever is signed in now.
|
||||
class StaleSessionException extends AppException {
|
||||
const StaleSessionException()
|
||||
: super(
|
||||
userMessage: 'Die Anmeldung hat sich geändert. Bitte lade neu.',
|
||||
technicalDetails: 'result of a previous session discarded',
|
||||
);
|
||||
}
|
||||
@@ -4,16 +4,22 @@ import 'app_exception.dart';
|
||||
class TalkException extends AppException {
|
||||
final TalkError source;
|
||||
|
||||
TalkException(this.source)
|
||||
TalkException(this.source, {String? technicalDetails})
|
||||
: super(
|
||||
userMessage: _mapMessage(source),
|
||||
technicalDetails:
|
||||
technicalDetails ??
|
||||
'Talk ${source.status} (${source.code}): ${source.message}',
|
||||
allowRetry: source.code >= 500,
|
||||
);
|
||||
|
||||
static String _mapMessage(TalkError e) {
|
||||
switch (e.code) {
|
||||
case 400:
|
||||
return 'Talk hat diese Anfrage abgelehnt. Bitte lade den Chat neu und '
|
||||
'versuche es erneut.';
|
||||
case 413:
|
||||
return 'Diese Nachricht ist zu lang.';
|
||||
case 401:
|
||||
return 'Bitte melde dich erneut an, um auf Talk zuzugreifen.';
|
||||
case 403:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'errors/auth_exception.dart';
|
||||
import 'errors/network_exception.dart';
|
||||
import 'errors/not_found_exception.dart';
|
||||
import 'errors/server_exception.dart';
|
||||
|
||||
/// Runs [send] and converts transport-level failures (socket/timeout/client
|
||||
/// errors) into a [NetworkException] tagged with [label] (e.g. `Talk <uri>`).
|
||||
/// Passes through whatever [send] produces, including `null` for the base-class
|
||||
/// request hooks that may skip the call.
|
||||
Future<http.Response?> sendGuarded(
|
||||
String label,
|
||||
Future<http.Response>? Function() send,
|
||||
) async {
|
||||
try {
|
||||
return await send();
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: '$label: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: '$label: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: '$label: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapses whitespace and caps an HTTP error body at 500 chars so it can be
|
||||
/// embedded in an [AppException]'s technical details without dumping headers.
|
||||
String previewBody(String body) {
|
||||
final collapsed = body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
return collapsed.length > 500 ? '${collapsed.substring(0, 500)}…' : collapsed;
|
||||
}
|
||||
|
||||
/// Builds a `<label> -> HTTP <status>[ body=<preview>]` technical detail line.
|
||||
String httpErrorDetail(String label, String body, int status) {
|
||||
final preview = previewBody(body);
|
||||
return preview.isEmpty
|
||||
? '$label -> HTTP $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);
|
||||
}
|
||||
@@ -16,9 +16,14 @@ 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: authorizationHeader == null
|
||||
? NextcloudOcs.headers()
|
||||
: {
|
||||
'Accept': 'application/json',
|
||||
'OCS-APIRequest': 'true',
|
||||
'Authorization': authorizationHeader,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +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
|
||||
@@ -18,21 +19,31 @@ class GetAppPassword {
|
||||
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
/// Returns the freshly minted app password. Throws on any transport or
|
||||
/// protocol error — callers treat push registration as best-effort and swallow
|
||||
/// failures.
|
||||
/// protocol error — a 401 becomes an [AuthException], which the login flow
|
||||
/// reads as "Nextcloud rejects the password" (two-factor authentication or
|
||||
/// password mismatch) and answers with the interactive Login Flow v2.
|
||||
Future<String> run() async {
|
||||
final response = await _client.get(
|
||||
const label = 'Nextcloud getapppassword';
|
||||
final response = (await sendGuarded(
|
||||
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(),
|
||||
'Authorization': SessionManager()
|
||||
.requireNextcloud()
|
||||
.realPasswordBasicAuthHeader,
|
||||
},
|
||||
);
|
||||
),
|
||||
))!;
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('getapppassword HTTP ${response.statusCode}');
|
||||
throwForStatus(
|
||||
response.statusCode,
|
||||
httpErrorDetail(label, response.body, response.statusCode),
|
||||
);
|
||||
}
|
||||
final json = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
final data = (json as Map)['ocs']?['data'];
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
import 'autocomplete_response.dart';
|
||||
|
||||
@@ -31,16 +31,18 @@ class AutocompleteApi {
|
||||
'limit': '10',
|
||||
},
|
||||
);
|
||||
final response = await http.get(uri, headers: NextcloudOcs.headers());
|
||||
final response = (await sendGuarded(
|
||||
'Autocomplete $uri',
|
||||
() => http
|
||||
.get(uri, headers: NextcloudOcs.headers())
|
||||
.timeout(const Duration(seconds: 20)),
|
||||
))!;
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw ServerException(
|
||||
statusCode: response.statusCode,
|
||||
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AutocompleteResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../errors/auth_exception.dart';
|
||||
import '../../errors/network_exception.dart';
|
||||
import '../../errors/not_found_exception.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../errors/parse_exception.dart';
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
/// Mix of two Nextcloud surfaces:
|
||||
@@ -32,40 +27,27 @@ 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();
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await perform(uri, headers);
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'Cloud $uri: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
|
||||
}
|
||||
final response = (await sendGuarded(
|
||||
'Cloud $uri',
|
||||
() => perform(uri, headers),
|
||||
))!;
|
||||
|
||||
final status = response.statusCode;
|
||||
if (status >= 200 && status < 300) return response;
|
||||
|
||||
final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
||||
final detail = body.isEmpty
|
||||
? 'Cloud $uri -> HTTP $status'
|
||||
: 'Cloud $uri -> HTTP $status body=$preview';
|
||||
final detail = httpErrorDetail('Cloud $uri', response.body, status);
|
||||
log(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);
|
||||
throwForStatus(status, detail);
|
||||
}
|
||||
|
||||
class SetUserAvatar {
|
||||
@@ -116,16 +98,13 @@ class GetUserInfo {
|
||||
try {
|
||||
final root = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final data =
|
||||
(root['ocs'] as Map<String, dynamic>)['data']
|
||||
as Map<String, dynamic>;
|
||||
(root['ocs'] as Map<String, dynamic>)['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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../../marianumconnect/auth/device_token_name.dart';
|
||||
|
||||
/// Nextcloud Login Flow v2 (`/index.php/login/v2`): interactive browser login
|
||||
/// that yields an app password. It is the only way to obtain working Nextcloud
|
||||
/// credentials when the account is protected by two-factor authentication —
|
||||
/// Basic auth with the real password is rejected server-side in that case.
|
||||
class LoginFlowApi {
|
||||
final http.Client _client;
|
||||
|
||||
LoginFlowApi({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
/// Starts a new flow. Nextcloud displays the request's User-Agent as the
|
||||
/// token name in the user's security settings, so the device token label is
|
||||
/// sent (`"Marianum Fulda App (Pixel 10)"`).
|
||||
Future<LoginFlowInit> start() async {
|
||||
final userAgent = await DeviceTokenName.resolve();
|
||||
final uri = _initUri();
|
||||
const label = 'Nextcloud login flow init';
|
||||
final response = (await sendGuarded(
|
||||
label,
|
||||
() => _client.post(
|
||||
uri,
|
||||
headers: {'Accept': 'application/json', 'User-Agent': userAgent},
|
||||
),
|
||||
))!;
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throwForStatus(
|
||||
response.statusCode,
|
||||
httpErrorDetail(label, response.body, response.statusCode),
|
||||
);
|
||||
}
|
||||
return LoginFlowInit.fromJson(
|
||||
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// Polls for the flow result: `null` while the browser login has not been
|
||||
/// completed yet (HTTP 404), the final credentials once it has.
|
||||
Future<LoginFlowCredentials?> poll(LoginFlowInit flow) async {
|
||||
const label = 'Nextcloud login flow poll';
|
||||
final response = (await sendGuarded(
|
||||
label,
|
||||
() => _client.post(
|
||||
Uri.parse(flow.pollEndpoint),
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: {'token': flow.pollToken},
|
||||
),
|
||||
))!;
|
||||
if (response.statusCode == 404) return null;
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throwForStatus(
|
||||
response.statusCode,
|
||||
httpErrorDetail(label, response.body, response.statusCode),
|
||||
);
|
||||
}
|
||||
return LoginFlowCredentials.fromJson(
|
||||
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
static Uri _initUri() {
|
||||
final endpoint = EndpointData().nextcloud();
|
||||
return Uri.https(endpoint.domain, '${endpoint.path}/index.php/login/v2');
|
||||
}
|
||||
|
||||
/// Whether the login name reported by the completed flow belongs to the
|
||||
/// account this app session expects — the browser login could have been
|
||||
/// completed with a different Nextcloud account.
|
||||
static bool loginNameMatches({
|
||||
required String expected,
|
||||
required String actual,
|
||||
}) => actual.trim().toLowerCase() == expected.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/// Response of the flow init call: the URL the user opens in the browser plus
|
||||
/// the token/endpoint pair the app polls until the login is confirmed.
|
||||
class LoginFlowInit {
|
||||
final String loginUrl;
|
||||
final String pollToken;
|
||||
final String pollEndpoint;
|
||||
|
||||
const LoginFlowInit({
|
||||
required this.loginUrl,
|
||||
required this.pollToken,
|
||||
required this.pollEndpoint,
|
||||
});
|
||||
|
||||
factory LoginFlowInit.fromJson(Map<String, dynamic> json) {
|
||||
final poll = json['poll'];
|
||||
final loginUrl = json['login'] as String?;
|
||||
final token = poll is Map ? poll['token'] as String? : null;
|
||||
final endpoint = poll is Map ? poll['endpoint'] as String? : null;
|
||||
if (loginUrl == null || loginUrl.isEmpty) {
|
||||
throw const FormatException('login flow init: missing login url');
|
||||
}
|
||||
if (token == null || token.isEmpty || endpoint == null || endpoint.isEmpty) {
|
||||
throw const FormatException('login flow init: missing poll token/endpoint');
|
||||
}
|
||||
return LoginFlowInit(
|
||||
loginUrl: loginUrl,
|
||||
pollToken: token,
|
||||
pollEndpoint: endpoint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Credentials returned once the user confirmed the login in the browser.
|
||||
class LoginFlowCredentials {
|
||||
final String server;
|
||||
final String loginName;
|
||||
final String appPassword;
|
||||
|
||||
const LoginFlowCredentials({
|
||||
required this.server,
|
||||
required this.loginName,
|
||||
required this.appPassword,
|
||||
});
|
||||
|
||||
factory LoginFlowCredentials.fromJson(Map<String, dynamic> json) {
|
||||
final loginName = json['loginName'] as String?;
|
||||
final appPassword = json['appPassword'] as String?;
|
||||
if (loginName == null || loginName.isEmpty) {
|
||||
throw const FormatException('login flow poll: missing loginName');
|
||||
}
|
||||
if (appPassword == null || appPassword.isEmpty) {
|
||||
throw const FormatException('login flow poll: missing appPassword');
|
||||
}
|
||||
return LoginFlowCredentials(
|
||||
server: json['server'] as String? ?? '',
|
||||
loginName: loginName,
|
||||
appPassword: appPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import '../../model/account_data.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
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.
|
||||
class NextcloudOcs {
|
||||
NextcloudOcs._();
|
||||
|
||||
/// Decodes an OCS v2 JSON envelope and returns its `ocs` object (the wrapper
|
||||
/// every response nests its `meta`/`data` under).
|
||||
static Map<String, dynamic> decode(String raw) =>
|
||||
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
|
||||
|
||||
static Map<String, String> headers() => {
|
||||
'Accept': 'application/json',
|
||||
'OCS-APIRequest': 'true',
|
||||
'Authorization': AccountData().getBasicAuthHeader(),
|
||||
'Authorization': SessionManager().requireNextcloud().basicAuthHeader,
|
||||
};
|
||||
|
||||
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -28,9 +27,7 @@ class SearchFiles {
|
||||
'Files search failed with ${response.statusCode}: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final ocs = decoded['ocs'] as Map<String, dynamic>;
|
||||
final data = ocs['data'] as Map<String, dynamic>;
|
||||
final data = NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
|
||||
return SearchFilesResponse.fromJson(data);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../api_params.dart';
|
||||
import '../../../api_response.dart';
|
||||
import '../room/get_room_response.dart';
|
||||
import '../talk_api.dart';
|
||||
|
||||
/// Small POST/DELETE-only Talk endpoints that have no response payload.
|
||||
@@ -105,3 +106,21 @@ class DeleteRoomAvatar extends TalkApi {
|
||||
Map<String, String>? headers,
|
||||
) => http.delete(uri, headers: headers);
|
||||
}
|
||||
|
||||
class SetNotificationLevel extends TalkApi {
|
||||
final String chatToken;
|
||||
final GetRoomResponseObjectParticipantNotificationLevel level;
|
||||
|
||||
SetNotificationLevel(this.chatToken, this.level)
|
||||
: super('v4/room/$chatToken/notify', null);
|
||||
|
||||
@override
|
||||
ApiResponse? assemble(String raw) => null;
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
Uri uri,
|
||||
ApiParams? body,
|
||||
Map<String, String>? headers,
|
||||
) => http.post(uri, headers: headers, body: {'level': '${level.apiValue}'});
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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';
|
||||
import 'get_chat_response.dart';
|
||||
@@ -15,10 +14,8 @@ class GetChat extends TalkApi<GetChatResponse> {
|
||||
: super('v1/chat/$chatToken', null, getParameters: params.toJson());
|
||||
|
||||
@override
|
||||
GetChatResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
||||
}
|
||||
GetChatResponse assemble(String raw) =>
|
||||
GetChatResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<Response> request(
|
||||
|
||||
Loaded 100 of 488 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user