Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5592f117b | ||
|
|
ada4d6a77c | ||
|
|
cd88cc75fc | ||
|
|
279b973e89 | ||
|
|
b90cda62da | ||
|
|
a79b376bb3 | ||
|
|
eddd294a34 | ||
|
|
c455bb1cae | ||
|
|
aca86c506a | ||
|
|
2664378b17 | ||
|
|
ee1240f71a | ||
|
|
3e856d0b1c | ||
|
|
8eddc8f771 | ||
|
|
27563a6dc1 | ||
|
|
5e65c4671a | ||
|
|
79b33ab248 | ||
|
|
1fd6264d10 | ||
|
|
2ca9feb49a | ||
|
|
2aed3d104c | ||
|
|
e6f74ca6fe | ||
|
|
0f6c388940 | ||
|
|
d6130584f2 | ||
|
|
56a497985b | ||
|
|
ed8e52f475 | ||
|
|
ef7d17033d | ||
|
|
e21560ed3d | ||
|
|
de866b12b4 | ||
|
|
4f31e68456 | ||
|
|
498d195138 | ||
|
|
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 |
@@ -362,3 +362,9 @@ android/fastlane/README.md.bak
|
|||||||
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
|
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
|
||||||
# bleiben eingecheckt, damit supply die Struktur vorfindet.
|
# bleiben eingecheckt, damit supply die Struktur vorfindet.
|
||||||
android/fastlane/metadata/android/**/images/**/*.png
|
android/fastlane/metadata/android/**/images/**/*.png
|
||||||
|
|
||||||
|
# Fastlane (App-Store-Upload)
|
||||||
|
ios/fastlane/report.xml
|
||||||
|
ios/fastlane/Preview.html
|
||||||
|
ios/fastlane/app-store-connect-api-key.json
|
||||||
|
ios/fastlane/screenshots/**/*.jpg
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MarianumMobile Client
|
# MarianumMobile Client
|
||||||
|
|
||||||
Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push).
|
Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -16,7 +16,8 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil
|
|||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/)
|
├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/)
|
||||||
|
├── session/ Session-Modell, SessionManager, SessionLifecycle, Konten-Index
|
||||||
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
|
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
|
||||||
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
||||||
├── view/ Screens
|
├── view/ Screens
|
||||||
@@ -51,6 +52,10 @@ lib/
|
|||||||
|
|
||||||
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
|
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
|
||||||
|
|
||||||
|
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()`. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
|
||||||
|
|
||||||
|
**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; der Request-Cache `CacheStore` folgt dem aktiven Konto mit eigenem Verzeichnis); nur die Settings liegen in der globalen Box. `SessionManager().sessionEpoch` steigt bei jedem Kontowechsel – asynchrone Arbeit prüft damit, ob ihr Ergebnis noch zum aktiven Konto gehört. Was ein Konto außerhalb seines Speichers hinterlässt (Tray, Push-Schlüssel, Downloads), räumt `SessionWipe` auf. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert.
|
||||||
|
|
||||||
## Build / Run
|
## Build / Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -65,13 +70,12 @@ flutter test # Tests (siehe test
|
|||||||
|
|
||||||
| Backend | Pfad | Zweck |
|
| Backend | Pfad | Zweck |
|
||||||
|---------------------------|-----------------------|----------------------------------------|
|
|---------------------------|-----------------------|----------------------------------------|
|
||||||
| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer |
|
| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Capabilities, Push |
|
||||||
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
|
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
|
||||||
| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten |
|
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
|
||||||
| Holiday-Calendar | `lib/api/holidays/` | Ferien |
|
|
||||||
|
|
||||||
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
|
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
|
`test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ analyzer:
|
|||||||
- "**/*.freezed.dart"
|
- "**/*.freezed.dart"
|
||||||
- "lib/firebase_options.dart"
|
- "lib/firebase_options.dart"
|
||||||
- "build/**"
|
- "build/**"
|
||||||
|
- android/**
|
||||||
|
- ios/**
|
||||||
|
- web/**
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
rules:
|
rules:
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ GeneratedPluginRegistrant.java
|
|||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
|
.kotlin/
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ android {
|
|||||||
coreLibraryDesugaringEnabled true
|
coreLibraryDesugaringEnabled true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = '17'
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
main.java.srcDirs += 'src/main/kotlin'
|
||||||
}
|
}
|
||||||
@@ -47,7 +43,6 @@ android {
|
|||||||
targetSdkVersion flutter.targetSdkVersion
|
targetSdkVersion flutter.targetSdkVersion
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
multiDexEnabled true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -59,11 +54,20 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
source '../..'
|
source '../..'
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTask"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
@@ -81,6 +81,7 @@
|
|||||||
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
|
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
|
||||||
|
android:enabled="@bool/widget_supported"
|
||||||
android:label="@string/widget_day_label"
|
android:label="@string/widget_day_label"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -93,6 +94,7 @@
|
|||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
|
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
|
||||||
|
android:enabled="@bool/widget_supported"
|
||||||
android:label="@string/widget_week_label"
|
android:label="@string/widget_week_label"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -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
|
package eu.mhsl.marianum.mobile.client
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
private val widgetChannel = "eu.mhsl.marianum.widget"
|
private val widgetChannel = "eu.mhsl.marianum.widget"
|
||||||
|
private val shareChannel = "eu.mhsl.marianum.share"
|
||||||
/// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation`
|
/// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation`
|
||||||
/// so the same intent isn't replayed on every resume.
|
/// so the same intent isn't replayed on every resume.
|
||||||
private var pendingTimetableTap: Boolean = false
|
private var pendingTimetableTap: Boolean = false
|
||||||
|
private val shareActions = setOf(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE)
|
||||||
|
|
||||||
|
/// receive_sharing_intent only emits warm shares to a live Dart listener and
|
||||||
|
/// does not buffer them. After a relaunch into an existing task the share
|
||||||
|
/// arrives via onNewIntent before Dart subscribed, so it is held here until
|
||||||
|
/// Dart reports `listenerReady`.
|
||||||
|
private var shareListenerReady = false
|
||||||
|
private var bufferedShare: Intent? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// Android replays the original launch intent when the activity is
|
||||||
|
// recreated from recents or after process death. A share or widget tap
|
||||||
|
// that was already handled would otherwise run again — for shares with
|
||||||
|
// temp files that are long gone.
|
||||||
|
val replayed = savedInstanceState != null ||
|
||||||
|
(intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0
|
||||||
|
if (replayed && (intent.action in shareActions || isWidgetTap(intent))) {
|
||||||
|
intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
}
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
@@ -26,17 +49,35 @@ class MainActivity : FlutterActivity() {
|
|||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MethodChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
shareChannel
|
||||||
|
).setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"listenerReady" -> {
|
||||||
|
shareListenerReady = true
|
||||||
|
bufferedShare?.let { flutterEngine.activityControlSurface.onNewIntent(it) }
|
||||||
|
bufferedShare = null
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
consumeIntentData(intent)
|
consumeIntentData(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
|
if (!shareListenerReady && intent.action in shareActions) {
|
||||||
|
bufferedShare = intent
|
||||||
|
}
|
||||||
consumeIntentData(intent)
|
consumeIntentData(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isWidgetTap(intent: Intent?): Boolean =
|
||||||
|
intent?.getBooleanExtra("widget_open_timetable", false) == true
|
||||||
|
|
||||||
private fun consumeIntentData(intent: Intent?) {
|
private fun consumeIntentData(intent: Intent?) {
|
||||||
if (intent?.getBooleanExtra("widget_open_timetable", false) == true) {
|
if (isWidgetTap(intent)) pendingTimetableTap = true
|
||||||
pendingTimetableTap = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
|
|||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
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>`.
|
* Flutter plugin resolves the receiver class as `<app-package>.<androidName>`.
|
||||||
*/
|
*/
|
||||||
class TimetableDayWidget : AppWidgetProvider() {
|
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(
|
override fun onUpdate(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import android.appwidget.AppWidgetManager
|
|||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||||
|
|
||||||
class TimetableWeekWidget : AppWidgetProvider() {
|
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(
|
override fun onUpdate(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.util.TimeZone
|
|||||||
// Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names
|
// Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names
|
||||||
// must stay in sync.
|
// must stay in sync.
|
||||||
enum class WidgetLessonStatus {
|
enum class WidgetLessonStatus {
|
||||||
REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT;
|
REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT, DUTY;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
|
fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
|
||||||
@@ -22,6 +22,7 @@ enum class WidgetLessonStatus {
|
|||||||
"irregular" -> IRREGULAR
|
"irregular" -> IRREGULAR
|
||||||
"teacherChanged" -> TEACHER_CHANGED
|
"teacherChanged" -> TEACHER_CHANGED
|
||||||
"event" -> EVENT
|
"event" -> EVENT
|
||||||
|
"duty" -> DUTY
|
||||||
else -> REGULAR
|
else -> REGULAR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,10 +34,15 @@ data class WidgetLesson(
|
|||||||
val subjectShort: String,
|
val subjectShort: String,
|
||||||
val subjectLong: String?,
|
val subjectLong: String?,
|
||||||
val room: 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 teacher: String?,
|
||||||
val originalTeacher: String?,
|
val originalTeacher: String?,
|
||||||
val status: WidgetLessonStatus,
|
val status: WidgetLessonStatus,
|
||||||
val customColor: String?,
|
val customColor: String?,
|
||||||
|
// Tile colour resolved in Dart (subject colours, palettes); null only for
|
||||||
|
// payloads written before the field existed.
|
||||||
|
val color: Int?,
|
||||||
val siblingCount: Int,
|
val siblingCount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +54,12 @@ data class WidgetPeriod(
|
|||||||
val virtualEndMinutes: Int,
|
val virtualEndMinutes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class WidgetDayInfo(
|
||||||
|
val date: Date,
|
||||||
|
val isHoliday: Boolean,
|
||||||
|
val holidayName: String?,
|
||||||
|
)
|
||||||
|
|
||||||
data class WidgetTimetableData(
|
data class WidgetTimetableData(
|
||||||
val fetchedAt: Date,
|
val fetchedAt: Date,
|
||||||
val anchorDate: Date,
|
val anchorDate: Date,
|
||||||
@@ -55,6 +67,8 @@ data class WidgetTimetableData(
|
|||||||
val periods: List<WidgetPeriod>,
|
val periods: List<WidgetPeriod>,
|
||||||
val isHoliday: Boolean,
|
val isHoliday: Boolean,
|
||||||
val holidayName: String?,
|
val holidayName: String?,
|
||||||
|
// Week payload only; empty for the day payload.
|
||||||
|
val days: List<WidgetDayInfo>,
|
||||||
)
|
)
|
||||||
|
|
||||||
object WidgetDataParser {
|
object WidgetDataParser {
|
||||||
@@ -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? {
|
fun parse(json: String?): WidgetTimetableData? {
|
||||||
if (json.isNullOrEmpty()) return null
|
if (json.isNullOrEmpty()) return null
|
||||||
return try {
|
return try {
|
||||||
@@ -100,6 +119,7 @@ object WidgetDataParser {
|
|||||||
originalTeacher = obj.stringOrNull("originalTeacher"),
|
originalTeacher = obj.stringOrNull("originalTeacher"),
|
||||||
status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")),
|
status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")),
|
||||||
customColor = obj.stringOrNull("customColor"),
|
customColor = obj.stringOrNull("customColor"),
|
||||||
|
color = parseColor(obj.stringOrNull("color")),
|
||||||
siblingCount = obj.optInt("siblingCount", 0),
|
siblingCount = obj.optInt("siblingCount", 0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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(
|
WidgetTimetableData(
|
||||||
fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(),
|
fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(),
|
||||||
anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(),
|
anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(),
|
||||||
@@ -125,6 +157,7 @@ object WidgetDataParser {
|
|||||||
periods = periods,
|
periods = periods,
|
||||||
isHoliday = root.optBoolean("isHoliday", false),
|
isHoliday = root.optBoolean("isHoliday", false),
|
||||||
holidayName = root.stringOrNull("holidayName"),
|
holidayName = root.stringOrNull("holidayName"),
|
||||||
|
days = days,
|
||||||
)
|
)
|
||||||
} catch (_: JSONException) {
|
} catch (_: JSONException) {
|
||||||
null
|
null
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+594
-332
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:viewportWidth="100"
|
||||||
android:viewportHeight="100">
|
android:viewportHeight="100">
|
||||||
<path
|
<path
|
||||||
android:strokeColor="#C8FF0000"
|
android:strokeColor="#C8F44336"
|
||||||
android:strokeWidth="5"
|
android:strokeWidth="2.5"
|
||||||
android:pathData="M0,0 L100,100 M100,0 L0,100" />
|
android:pathData="M0,0 L100,100 M100,0 L0,100" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:shape="rectangle">
|
android:shape="oval">
|
||||||
<corners android:radius="6dp" />
|
<solid android:color="@android:color/white" />
|
||||||
<solid android:color="#FFEF6C00" />
|
|
||||||
</shape>
|
</shape>
|
||||||
@@ -3,6 +3,14 @@
|
|||||||
android:id="@+id/widget_break_block_root"
|
android:id="@+id/widget_break_block_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="14dp"
|
android:layout_height="14dp"
|
||||||
android:layout_marginTop="0dp"
|
android:layout_marginTop="0dp">
|
||||||
android:layout_marginStart="2dp"
|
|
||||||
android:layout_marginEnd="2dp" />
|
<ImageView
|
||||||
|
android:id="@+id/widget_break_block_icon"
|
||||||
|
android:layout_width="12dp"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/widget_icon_restaurant" />
|
||||||
|
</FrameLayout>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_break_label_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:layout_marginTop="0dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_break_label_icon"
|
||||||
|
android:layout_width="10dp"
|
||||||
|
android:layout_height="10dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/widget_icon_coffee" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
|
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
|
||||||
the actual widget content without disturbing the LinearLayout flow. -->
|
the actual widget content without disturbing the LinearLayout flow.
|
||||||
|
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
|
||||||
|
height from them, so they must not follow the system font scale. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_root"
|
android:id="@+id/widget_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@drawable/app_widget_background">
|
android:background="@drawable/app_widget_background"
|
||||||
|
tools:ignore="SpUsage,SmallSp">
|
||||||
|
|
||||||
<!-- Bottom-leading so the mark looks like it's peeking out of the lower
|
<!-- Width/height/end+bottom margins are overridden in the renderer so
|
||||||
left corner. Width/height/start+bottom margins are overridden in the
|
the mark scales with the widget size. -->
|
||||||
renderer so the mark scales with the widget size. -->
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/widget_watermark"
|
android:id="@+id/widget_watermark"
|
||||||
android:layout_width="160dp"
|
android:layout_width="160dp"
|
||||||
@@ -26,43 +28,68 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp">
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="20dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical">
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_day_title"
|
android:id="@+id/widget_day_title"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@color/widget_text_primary"
|
android:textColor="@color/widget_text_primary"
|
||||||
android:singleLine="true"
|
android:maxLines="1"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="9dp"
|
||||||
|
android:autoSizeMaxTextSize="14dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
tools:text="Heute · 08.05." />
|
tools:text="Heute · 08.05." />
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_day_subtitle"
|
android:id="@+id/widget_day_subtitle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="10sp"
|
android:layout_marginStart="6dp"
|
||||||
|
android:textSize="10dp"
|
||||||
android:textColor="@color/widget_text_secondary"
|
android:textColor="@color/widget_text_secondary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
tools:text="Stand: 14:32" />
|
tools:text="Stand: 14:32" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Narrow day widgets can't fit title and freshness side by side —
|
||||||
|
the renderer moves the freshness label down here instead. -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_day_subtitle_below"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:textSize="9dp"
|
||||||
|
android:textColor="@color/widget_text_secondary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
tools:text="Stand: 14:32" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="4dp"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_day_time_labels"
|
android:id="@+id/widget_day_time_labels"
|
||||||
android:layout_width="32dp"
|
android:layout_width="30dp"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static picker preview of the day widget, laid out like WidgetRenderer's output. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@color/widget_background">
|
android:background="@color/widget_background"
|
||||||
|
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="160dp"
|
android:layout_width="160dp"
|
||||||
@@ -20,164 +23,126 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp">
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
<LinearLayout
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="20dp"
|
||||||
android:orientation="horizontal"
|
android:gravity="start|center_vertical"
|
||||||
android:gravity="center_vertical">
|
android:includeFontPadding="false"
|
||||||
<TextView
|
android:textSize="14dp"
|
||||||
android:layout_width="0dp"
|
android:textStyle="bold"
|
||||||
android:layout_height="wrap_content"
|
android:textColor="@color/widget_text_primary"
|
||||||
android:layout_weight="1"
|
android:maxLines="1"
|
||||||
android:textSize="14sp"
|
android:text="Heute · 06.05." />
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:text="Heute · 06.05." />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="10sp"
|
|
||||||
android:textColor="@color/widget_text_secondary"
|
|
||||||
android:text="Stand: 07:50" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:orientation="horizontal"
|
android:layout_marginTop="4dp"
|
||||||
android:layout_marginTop="6dp">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
|
||||||
android:layout_width="32dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
android:layout_height="match_parent">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" />
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
|
||||||
android:layout_marginTop="22dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="1." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="50dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="2." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="79dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="3." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="115dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="4." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="142dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="5." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="169dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="6." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_weight="1">
|
|
||||||
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="29dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="56dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="83dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="115dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="143dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="170dp" android:background="@color/widget_divider" />
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="22dp"
|
|
||||||
android:layout_marginTop="29dp"
|
|
||||||
android:layout_marginStart="2dp"
|
|
||||||
android:layout_marginEnd="2dp"
|
|
||||||
android:padding="3dp"
|
|
||||||
android:background="@drawable/widget_lesson_block_regular">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@android:color/white" android:text="MA-LK" />
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="2" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:25" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="72dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="3" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:15" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp">
|
||||||
|
<ImageView android:layout_width="10dp" android:layout_height="10dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_text_secondary" android:src="@drawable/widget_icon_coffee" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="122dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:20" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="158dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:10" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="194dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:00" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="230dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:05" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:50" />
|
||||||
|
</FrameLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
android:layout_width="match_parent"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
android:layout_height="55dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
android:layout_marginTop="83dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
android:layout_marginStart="2dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
android:layout_marginEnd="2dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
android:padding="3dp"
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
android:background="@drawable/widget_lesson_block_regular">
|
</FrameLayout>
|
||||||
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
android:orientation="vertical">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
android:textColor="@android:color/white" android:text="DE-GK" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
android:textSize="9sp" android:textColor="#CCFFFFFF" android:text="B11" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
<FrameLayout
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
android:layout_width="match_parent"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
|
||||||
android:layout_height="22dp"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
|
||||||
android:layout_marginTop="143dp"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
|
||||||
android:layout_marginStart="2dp"
|
</LinearLayout>
|
||||||
android:layout_marginEnd="2dp"
|
|
||||||
android:padding="3dp"
|
|
||||||
android:background="@drawable/widget_lesson_block_cancelled">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@android:color/white" android:text="BIO" />
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
|
||||||
<FrameLayout
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
android:layout_width="match_parent"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
|
||||||
android:layout_height="22dp"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
|
||||||
android:layout_marginTop="170dp"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
|
||||||
android:layout_marginStart="2dp"
|
</LinearLayout>
|
||||||
android:layout_marginEnd="2dp"
|
</FrameLayout>
|
||||||
android:padding="3dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#000000">
|
||||||
android:background="@drawable/widget_lesson_block_teacher_changed">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
|
||||||
android:textColor="@android:color/white" android:text="GE" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
|
||||||
|
</LinearLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="230.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#29639B">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
|
||||||
|
holiday name rotated along the column. The name's width is set by the
|
||||||
|
renderer — after the rotation it is the length along the column. -->
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/widget_holiday_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:ignore="SpUsage,SmallSp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center_horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_holiday_icon"
|
||||||
|
android:layout_width="14dp"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/widget_icon_event" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_holiday_label"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:paddingStart="2dp"
|
||||||
|
android:paddingEnd="2dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/widget_school_free"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="6dp"
|
||||||
|
android:autoSizeMaxTextSize="9dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_holiday_name"
|
||||||
|
android:layout_width="120dp"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:rotation="90"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:letterSpacing="0.1"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="7dp"
|
||||||
|
android:autoSizeMaxTextSize="11dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
|
tools:text="Herbstferien" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -1,90 +1,113 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Mirrors AppointmentTile: title on top, room and teacher stacked below,
|
||||||
|
each line shrinking on its own to fit the tile width. Text sizes are dp
|
||||||
|
(not sp) because the line boxes are sized in dp by the renderer. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_lesson_block_root"
|
android:id="@+id/widget_lesson_block_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="40dp"
|
android:layout_height="40dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="1dp"
|
||||||
android:layout_marginEnd="2dp"
|
android:layout_marginEnd="1dp"
|
||||||
android:layout_marginTop="0dp"
|
android:layout_marginTop="0dp"
|
||||||
android:paddingStart="7dp"
|
android:background="@drawable/widget_lesson_block"
|
||||||
android:paddingEnd="7dp"
|
tools:ignore="SpUsage,SmallSp">
|
||||||
android:paddingTop="3dp"
|
|
||||||
android:paddingBottom="3dp"
|
|
||||||
android:background="@drawable/widget_lesson_block_regular">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="horizontal"
|
android:orientation="vertical"
|
||||||
android:gravity="top"
|
android:paddingStart="4dp"
|
||||||
android:baselineAligned="false">
|
android:paddingEnd="4dp"
|
||||||
|
android:paddingTop="2dp"
|
||||||
|
android:paddingBottom="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/widget_lesson_title_row"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:baselineAligned="false">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_lesson_subject"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:fontFamily="sans-serif-medium"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="6.5dp"
|
||||||
|
android:autoSizeMaxTextSize="15dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
|
tools:text="MA-LK" />
|
||||||
|
|
||||||
|
<!-- Only for tiles too short for a body line below the title. -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_lesson_inline"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
android:layout_marginStart="3dp"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="7dp"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:text="B11" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_lesson_subject"
|
android:id="@+id/widget_lesson_room"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="10dp"
|
||||||
android:layout_weight="1"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:gravity="top|start"
|
android:gravity="start|center_vertical"
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
android:autoSizeTextType="uniform"
|
android:autoSizeTextType="uniform"
|
||||||
android:autoSizeMinTextSize="5sp"
|
android:autoSizeMinTextSize="5.5dp"
|
||||||
android:autoSizeMaxTextSize="11sp"
|
android:autoSizeMaxTextSize="10dp"
|
||||||
android:autoSizeStepGranularity="1sp"
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
tools:text="MA-LK" />
|
tools:text="A12" />
|
||||||
|
|
||||||
<LinearLayout
|
<TextView
|
||||||
android:id="@+id/widget_lesson_secondary_stack"
|
android:id="@+id/widget_lesson_teacher"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="10dp"
|
||||||
android:layout_weight="1"
|
android:textColor="@android:color/white"
|
||||||
android:orientation="vertical"
|
android:maxLines="1"
|
||||||
android:gravity="top|end"
|
android:ellipsize="end"
|
||||||
android:layout_marginStart="4dp">
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
<TextView
|
android:autoSizeTextType="uniform"
|
||||||
android:id="@+id/widget_lesson_room"
|
android:autoSizeMinTextSize="5.5dp"
|
||||||
android:layout_width="wrap_content"
|
android:autoSizeMaxTextSize="10dp"
|
||||||
android:layout_height="12dp"
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
android:textColor="#CCFFFFFF"
|
tools:text="Müller" />
|
||||||
android:maxLines="1"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:gravity="end|top"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:autoSizeTextType="uniform"
|
|
||||||
android:autoSizeMinTextSize="5sp"
|
|
||||||
android:autoSizeMaxTextSize="11sp"
|
|
||||||
android:autoSizeStepGranularity="1sp"
|
|
||||||
tools:text="A12" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/widget_lesson_teacher"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="12dp"
|
|
||||||
android:textColor="#B3FFFFFF"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:gravity="end|top"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:autoSizeTextType="uniform"
|
|
||||||
android:autoSizeMinTextSize="5sp"
|
|
||||||
android:autoSizeMaxTextSize="11sp"
|
|
||||||
android:autoSizeStepGranularity="1sp"
|
|
||||||
tools:text="Müller" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/widget_lesson_cancelled"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/widget_lesson_cancelled_overlay"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_lesson_sibling_badge"
|
android:id="@+id/widget_lesson_sibling_badge"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="bottom|start"
|
android:layout_gravity="bottom|end"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:layout_marginBottom="2dp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textSize="12sp"
|
android:textSize="9dp"
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
|
|||||||
@@ -1,31 +1,50 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<!-- One period cell of the ruler, mirroring the in-app _PeriodLabel: number
|
||||||
|
centred, start time above, end time below. -->
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_time_label_root"
|
android:id="@+id/widget_time_label_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="36dp"
|
||||||
android:layout_marginTop="0dp"
|
android:layout_marginTop="0dp"
|
||||||
android:orientation="vertical"
|
tools:ignore="SpUsage,SmallSp">
|
||||||
android:gravity="end"
|
|
||||||
android:paddingEnd="3dp">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_time_label_time"
|
android:id="@+id/widget_time_label_start"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="9sp"
|
android:layout_gravity="top"
|
||||||
android:textColor="@color/widget_text_primary"
|
android:layout_marginTop="2dp"
|
||||||
android:singleLine="true"
|
android:gravity="center_horizontal"
|
||||||
android:lineSpacingExtra="-2dp"
|
android:textSize="7dp"
|
||||||
|
android:textColor="@color/widget_text_secondary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
tools:text="07:55" />
|
tools:text="07:55" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_time_label_number"
|
android:id="@+id/widget_time_label_number"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="7sp"
|
android:layout_gravity="center_vertical"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:fontFamily="sans-serif-medium"
|
||||||
|
android:textSize="11dp"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
tools:text="1" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_time_label_end"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:layout_marginBottom="2dp"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:textSize="7dp"
|
||||||
android:textColor="@color/widget_text_secondary"
|
android:textColor="@color/widget_text_secondary"
|
||||||
android:singleLine="true"
|
android:maxLines="1"
|
||||||
android:lineSpacingExtra="-2dp"
|
android:includeFontPadding="false"
|
||||||
tools:text="1." />
|
tools:text="08:40" />
|
||||||
</LinearLayout>
|
</FrameLayout>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
|
||||||
|
the actual widget content without disturbing the LinearLayout flow.
|
||||||
|
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
|
||||||
|
height from them, so they must not follow the system font scale. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_root"
|
android:id="@+id/widget_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@drawable/app_widget_background">
|
android:background="@drawable/app_widget_background"
|
||||||
|
tools:ignore="SpUsage,SmallSp">
|
||||||
|
|
||||||
|
<!-- Width/height/end+bottom margins are overridden in the renderer so
|
||||||
|
the mark scales with the widget size. -->
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/widget_watermark"
|
android:id="@+id/widget_watermark"
|
||||||
android:layout_width="160dp"
|
android:layout_width="160dp"
|
||||||
@@ -21,42 +28,53 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp">
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="20dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical">
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_week_title"
|
android:id="@+id/widget_week_title"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@color/widget_text_primary"
|
android:textColor="@color/widget_text_primary"
|
||||||
android:singleLine="true"
|
android:maxLines="1"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="9dp"
|
||||||
|
android:autoSizeMaxTextSize="14dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
tools:text="KW 19 · 06.05.–10.05." />
|
tools:text="KW 19 · 06.05.–10.05." />
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_week_subtitle"
|
android:id="@+id/widget_week_subtitle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="10sp"
|
android:layout_marginStart="6dp"
|
||||||
|
android:textSize="10dp"
|
||||||
android:textColor="@color/widget_text_secondary"
|
android:textColor="@color/widget_text_secondary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
tools:text="Stand: 14:32" />
|
tools:text="Stand: 14:32" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="32dp"
|
||||||
android:orientation="horizontal"
|
android:layout_marginTop="4dp"
|
||||||
android:layout_marginTop="6dp">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:layout_width="28dp"
|
android:layout_width="30dp"
|
||||||
android:layout_height="wrap_content" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:layout_width="1dp"
|
android:layout_width="1dp"
|
||||||
@@ -66,7 +84,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_mon"
|
android:id="@+id/widget_week_header_mon"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@@ -77,7 +95,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_tue"
|
android:id="@+id/widget_week_header_tue"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@@ -88,7 +106,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_wed"
|
android:id="@+id/widget_week_header_wed"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@@ -99,7 +117,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_thu"
|
android:id="@+id/widget_week_header_thu"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@@ -110,7 +128,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_fri"
|
android:id="@+id/widget_week_header_fri"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -123,7 +141,7 @@
|
|||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_time_labels"
|
android:id="@+id/widget_week_time_labels"
|
||||||
android:layout_width="28dp"
|
android:layout_width="30dp"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
|
|||||||
@@ -1,28 +1,35 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
|
||||||
|
today inside an accent circle. -->
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_week_day_header_root"
|
android:id="@+id/widget_week_day_header_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingTop="2dp"
|
tools:ignore="SpUsage,SmallSp">
|
||||||
android:paddingBottom="3dp">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_week_day_header_weekday"
|
android:id="@+id/widget_week_day_header_weekday"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="11sp"
|
android:fontFamily="sans-serif-medium"
|
||||||
android:textStyle="bold"
|
android:textSize="9dp"
|
||||||
android:textColor="@color/widget_text_primary"
|
android:textColor="@color/widget_text_secondary"
|
||||||
tools:text="Mo" />
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
tools:text="MO." />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_week_day_header_date"
|
android:id="@+id/widget_week_day_header_date"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="18dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="18dp"
|
||||||
android:textSize="9sp"
|
android:layout_marginTop="1dp"
|
||||||
android:textColor="@color/widget_text_secondary"
|
android:gravity="center"
|
||||||
tools:text="06.05." />
|
android:textSize="11dp"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
tools:text="6" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Static preview for the week widget. Five day columns with two short
|
<!-- Static picker preview of the week widget, laid out like WidgetRenderer's output. -->
|
||||||
demo blocks each so the structure is recognisable in the picker. -->
|
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@color/widget_background">
|
android:background="@color/widget_background"
|
||||||
|
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="200dp"
|
android:layout_width="200dp"
|
||||||
@@ -22,124 +23,312 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp">
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="14dp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="KW 19 · 04.05.–08.05." />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="32dp"
|
||||||
android:orientation="horizontal"
|
android:layout_marginTop="4dp"
|
||||||
android:gravity="center_vertical">
|
android:orientation="horizontal">
|
||||||
<TextView
|
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent" />
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:text="KW 19 · 06.05.–10.05." />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="10sp"
|
|
||||||
android:textColor="@color/widget_text_secondary"
|
|
||||||
android:text="Stand: 07:50" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- Day-name + date row -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:layout_marginTop="6dp">
|
|
||||||
<FrameLayout android:layout_width="20dp" android:layout_height="wrap_content" />
|
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mo" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MO." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="06.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Di" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DI." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="07.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mi" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MI." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="08.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Do" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DO." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="09.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Fr" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="FR." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="10.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="8" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Time grid: time-label column + 5 day columns -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:orientation="horizontal"
|
android:layout_marginTop="2dp"
|
||||||
android:layout_marginTop="2dp">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<FrameLayout android:layout_width="20dp" android:layout_height="match_parent">
|
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="0dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="08" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="36dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="10" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="72dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="12" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
|
||||||
</FrameLayout>
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" />
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
|
||||||
|
|
||||||
<!-- Mon -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="2" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:25" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="72dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="3" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:15" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp">
|
||||||
|
<ImageView android:layout_width="10dp" android:layout_height="10dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_text_secondary" android:src="@drawable/widget_icon_coffee" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="122dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:20" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="158dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:10" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="194dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:00" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="230dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:05" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:50" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<!-- Tue -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_cancelled">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="BIO" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="60dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="DE" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#000000">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
|
||||||
|
</LinearLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<!-- Wed -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="32dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#26A69A">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="SPO" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="TH1" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BAU" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#8F19B3">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PoWi" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A105" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="HOF" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="84.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#29639B">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<!-- Thu -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_teacher_changed">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="GE" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="PH" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#E91E63">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="CH" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N305" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MEI" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="194.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<!-- Fri -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#78909C">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EK" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A301" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LAN" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#7E57C2">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MU" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="M1" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SIN" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#F4511E">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="KU" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="K2" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MAL" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#FFA000">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="LA" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A108" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="CIC" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -4,4 +4,6 @@
|
|||||||
<color name="widget_text_primary">#FFF1F1F1</color>
|
<color name="widget_text_primary">#FFF1F1F1</color>
|
||||||
<color name="widget_text_secondary">#FFB0B0B0</color>
|
<color name="widget_text_secondary">#FFB0B0B0</color>
|
||||||
<color name="widget_divider">#33FFFFFF</color>
|
<color name="widget_divider">#33FFFFFF</color>
|
||||||
|
<color name="widget_preview_accent">#FFFFB3AF</color>
|
||||||
|
<color name="widget_preview_break">#32FFB3AF</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<bool name="widget_supported">true</bool>
|
||||||
|
</resources>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string>
|
<string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string>
|
||||||
<string name="widget_no_lessons">Keine Stunden</string>
|
<string name="widget_no_lessons">Keine Stunden</string>
|
||||||
<string name="widget_holiday">Ferien</string>
|
<string name="widget_holiday">Ferien</string>
|
||||||
|
<string name="widget_school_free">Schulfrei</string>
|
||||||
<string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string>
|
<string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string>
|
||||||
<string name="widget_loading">Lade…</string>
|
<string name="widget_loading">Lade…</string>
|
||||||
<string name="widget_status_label">Stand: %1$s</string>
|
<string name="widget_status_label">Stand: %1$s</string>
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Status colors mirror lib/view/pages/timetable/data/lesson_color.dart
|
|
||||||
exactly so widget tiles match the in-app calendar. -->
|
|
||||||
<color name="widget_lesson_regular">#FF993333</color>
|
|
||||||
<color name="widget_lesson_ongoing">#FFC83333</color>
|
|
||||||
<color name="widget_lesson_past">#FF993333</color>
|
|
||||||
<color name="widget_lesson_cancelled">#FF000000</color>
|
|
||||||
<color name="widget_lesson_irregular">#FF8F19B3</color>
|
|
||||||
<color name="widget_lesson_teacher_changed">#FF29639B</color>
|
|
||||||
<color name="widget_lesson_event">#FF2E7D32</color>
|
|
||||||
|
|
||||||
<color name="widget_background">#FFFCF7F5</color>
|
<color name="widget_background">#FFFCF7F5</color>
|
||||||
<color name="widget_text_primary">#FF111111</color>
|
<color name="widget_text_primary">#FF111111</color>
|
||||||
<color name="widget_text_secondary">#FF555555</color>
|
<color name="widget_text_secondary">#FF555555</color>
|
||||||
<color name="widget_divider">#22000000</color>
|
<color name="widget_divider">#22000000</color>
|
||||||
|
<color name="widget_preview_accent">#FF904A47</color>
|
||||||
|
<color name="widget_preview_break">#32904A47</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- WidgetRenderer positions every tile with RemoteViews calls that only
|
||||||
|
exist from Android 12 (API 31). Below that the widgets are not
|
||||||
|
offered at all rather than showing up empty. -->
|
||||||
|
<bool name="widget_supported">false</bool>
|
||||||
|
</resources>
|
||||||
+18
-3
@@ -5,10 +5,25 @@ allprojects {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.buildDir = '../build'
|
rootProject.layout.buildDirectory = file('../build')
|
||||||
subprojects {
|
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
|
// 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
|
// 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
|
// Java compatibility (e.g. home_widget at 1.8) don't break the build under
|
||||||
@@ -42,5 +57,5 @@ subprojects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete) {
|
tasks.register("clean", Delete) {
|
||||||
delete rootProject.buildDir
|
delete rootProject.layout.buildDirectory
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx4G
|
org.gradle.jvmargs=-Xmx4G
|
||||||
android.useAndroidX=true
|
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
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
android.newDsl=false
|
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
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
id "com.android.application" version '8.13.2' apply false
|
id "com.android.application" version '9.1.0' apply false
|
||||||
id "com.android.library" version '8.13.2' apply false
|
id "com.android.library" version '9.1.0' apply false
|
||||||
id "org.jetbrains.kotlin.android" version "2.2.20" apply false
|
id "org.jetbrains.kotlin.android" version "2.4.0" apply false
|
||||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ targets:
|
|||||||
json_serializable:
|
json_serializable:
|
||||||
options:
|
options:
|
||||||
explicit_to_json: false
|
explicit_to_json: false
|
||||||
|
checked: true
|
||||||
generic_argument_factories: true
|
generic_argument_factories: true
|
||||||
@@ -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)
|
||||||
@@ -17,7 +17,8 @@ import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
|
|||||||
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
|
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
|
||||||
/// und Einstellungen. Ausgeführt über `flutter drive` mit
|
/// und Einstellungen. Ausgeführt über `flutter drive` mit
|
||||||
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
|
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
|
||||||
/// (Phone / 7″ / 10″).
|
/// (Android: Phone / 7″ / 10″) bzw. tool/screenshots_ios.sh (iPhone 6,5″ /
|
||||||
|
/// iPad 13″).
|
||||||
///
|
///
|
||||||
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
|
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
|
||||||
/// Fixtures, daher ist der Lauf deterministisch.
|
/// Fixtures, daher ist der Lauf deterministisch.
|
||||||
@@ -42,11 +43,13 @@ Future<void> main() async {
|
|||||||
_log('Login');
|
_log('Login');
|
||||||
await _login(tester);
|
await _login(tester);
|
||||||
|
|
||||||
|
// Großzügig: auf einem frisch angelegten iOS-Simulator dauert der erste
|
||||||
|
// Post-Login-Aufbau deutlich länger als 20s.
|
||||||
_log('warte auf App-Shell');
|
_log('warte auf App-Shell');
|
||||||
await _pumpUntil(
|
await _pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
find.byType(App),
|
find.byType(App),
|
||||||
timeout: const Duration(seconds: 20),
|
timeout: const Duration(seconds: 60),
|
||||||
);
|
);
|
||||||
_log('warte auf Post-Login-Splash-Ende');
|
_log('warte auf Post-Login-Splash-Ende');
|
||||||
await _pumpUntilGone(
|
await _pumpUntilGone(
|
||||||
@@ -64,6 +67,12 @@ Future<void> main() async {
|
|||||||
await binding.convertFlutterSurfaceToImage();
|
await binding.convertFlutterSurfaceToImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Den einmaligen Benachrichtigungs-Dialog beim ersten Talk-Besuch
|
||||||
|
// unterdrücken. Auf Android erteilt tool/screenshots.sh die Permission
|
||||||
|
// vorab, auf iOS lässt sie sich im Simulator nicht vorab erteilen – der
|
||||||
|
// Dialog würde sonst über der Talk-Liste liegen.
|
||||||
|
_markTalkPermissionPromptShown(tester);
|
||||||
|
|
||||||
// 1 + 2: Stundenplan in beiden Themes.
|
// 1 + 2: Stundenplan in beiden Themes.
|
||||||
await _goTo(tester, Modules.timetable);
|
await _goTo(tester, Modules.timetable);
|
||||||
_setTheme(tester, ThemeMode.light);
|
_setTheme(tester, ThemeMode.light);
|
||||||
@@ -129,6 +138,16 @@ void _setTheme(WidgetTester tester, ThemeMode mode) {
|
|||||||
context.read<SettingsCubit>().val(write: true).appTheme = mode;
|
context.read<SettingsCubit>().val(write: true).appTheme = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _markTalkPermissionPromptShown(WidgetTester tester) {
|
||||||
|
final context = tester.element(find.byType(App));
|
||||||
|
context
|
||||||
|
.read<SettingsCubit>()
|
||||||
|
.val(write: true)
|
||||||
|
.notificationSettings
|
||||||
|
.talkPermissionPromptShown =
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt,
|
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt,
|
||||||
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
|
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
|
||||||
Future<void> _goTo(WidgetTester tester, Modules module) async {
|
Future<void> _goTo(WidgetTester tester, Modules module) async {
|
||||||
|
|||||||
@@ -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
|
/// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart
|
||||||
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
|
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
|
||||||
/// extension. Wrong value here => keychain reads return nil => placeholder.
|
/// 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 devicePrivateKeyAccount = "push_device_private_key_pem"
|
||||||
private static let serverPublicKeyAccount = "push_server_public_key_pem"
|
private static let serverPublicKeyAccount = "push_server_public_key_pem"
|
||||||
@@ -233,7 +233,7 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
/// kSecClass = kSecClassGenericPassword
|
/// kSecClass = kSecClassGenericPassword
|
||||||
/// kSecAttrAccount = the Dart key, verbatim
|
/// kSecAttrAccount = the Dart key, verbatim
|
||||||
/// kSecAttrService = (unset — the Dart IOSOptions set no accountName)
|
/// 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
|
/// value = raw UTF-8 bytes of the string
|
||||||
private func keychainString(_ account: String) -> String? {
|
private func keychainString(_ account: String) -> String? {
|
||||||
let query: [CFString: Any] = [
|
let query: [CFString: Any] = [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
+38
-12
@@ -55,8 +55,9 @@ iOS zeigt die fertige Notification
|
|||||||
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
|
| `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_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
|
||||||
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
|
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
|
||||||
| **Xcode-Target „NotificationServiceExtension"** | **FEHLT** | muss in Xcode angelegt werden (Abschnitt 3) |
|
| **Xcode-Target „NotificationServiceExtension"** | **existiert** | programmatisch via `xcodeproj`-Gem angelegt (2026-07-07), gespiegelt an der Share-Extension |
|
||||||
| `ios/Runner.xcodeproj/project.pbxproj` | **unverändert** | bewusst NICHT von Hand editiert — Xcode legt das Target an |
|
| `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
|
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
|
||||||
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
|
> 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)
|
## 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
|
### 3.1 Target anlegen
|
||||||
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
|
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
|
||||||
2. **File → New → Target… → iOS → Notification Service Extension**.
|
2. **File → New → Target… → iOS → Notification Service Extension**.
|
||||||
@@ -132,7 +142,18 @@ iOS zeigt die fertige Notification
|
|||||||
## 4. Ermittelte Keychain-Details (verbindlich)
|
## 4. Ermittelte Keychain-Details (verbindlich)
|
||||||
|
|
||||||
Die Dart-Seite schreibt mit
|
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
|
Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
|
||||||
`pubspec.lock`) ergibt sich die exakte Ablage im Keychain:
|
`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` |
|
| `kSecClass` | `kSecClassGenericPassword` |
|
||||||
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
|
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
|
||||||
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
|
| `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`) |
|
| `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) |
|
||||||
| Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) |
|
| 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_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
|
||||||
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
|
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
|
||||||
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
|
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
|
||||||
| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) |
|
| `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) |
|
||||||
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
|
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
|
||||||
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
|
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
|
||||||
|
|
||||||
> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage
|
> `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers
|
||||||
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
|
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
|
||||||
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
|
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
|
||||||
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
|
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
|
||||||
@@ -278,9 +299,14 @@ ist der fragilste Teil und **muss auf dem Gerät verifiziert werden**:
|
|||||||
innerhalb des NSE-Budgets). Nicht implementiert.
|
innerhalb des NSE-Budgets). Nicht implementiert.
|
||||||
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
|
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
|
||||||
vor dem Release erledigen und im Archive gegenchecken (5.2).
|
vor dem Release erledigen und im Archive gegenchecken (5.2).
|
||||||
4. **Keychain-Access-Group-Schreibweise.** Die Entitlements listen die App-Group
|
4. **Keychain-Access-Group-Schreibweise (gelöst 2026-07-07).** Die Entitlements
|
||||||
ohne `$(AppIdentifierPrefix)` als `keychain-access-groups`. Das ist das von
|
listen `$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push` als
|
||||||
`flutter_secure_storage` erwartete Verhalten (Access-Group == App-Group-ID).
|
`keychain-access-groups` (team-prefixed, **keine** App-Group). Damit greift das
|
||||||
Sollte der Keychain-Zugriff wider Erwarten scheitern (Status `-34018` /
|
`<TeamID>.*` der Xcode-Profile und Automatic Signing läuft ohne Portal-Änderung
|
||||||
`errSecMissingEntitlement`), in **beiden** Targets die Keychain-Sharing-
|
durch (verifiziert: `flutter build ios --release` signiert Runner **und** NSE
|
||||||
Capability über die Xcode-UI neu setzen und Provisioning-Profile erneuern.
|
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 (1.0.0)
|
||||||
- flutter_app_badge (2.0.0):
|
- flutter_app_badge (2.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- home_widget (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- open_filex (0.0.2):
|
- open_filex (0.0.2):
|
||||||
- Flutter
|
- Flutter
|
||||||
- PhoneNumberKit (3.7.11):
|
- PhoneNumberKit (3.7.11):
|
||||||
@@ -14,20 +12,13 @@ PODS:
|
|||||||
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
|
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
|
||||||
- PhoneNumberKit/UIKit (3.7.11):
|
- PhoneNumberKit/UIKit (3.7.11):
|
||||||
- PhoneNumberKit/PhoneNumberKitCore
|
- PhoneNumberKit/PhoneNumberKitCore
|
||||||
- receive_sharing_intent (1.8.1):
|
|
||||||
- Flutter
|
|
||||||
- workmanager_apple (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- eraser (from `.symlinks/plugins/eraser/ios`)
|
- eraser (from `.symlinks/plugins/eraser/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
- 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`)
|
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
||||||
- PhoneNumberKit (~> 3.7.6)
|
- PhoneNumberKit (~> 3.7.6)
|
||||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
|
||||||
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
|
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
@@ -40,25 +31,16 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_app_badge:
|
flutter_app_badge:
|
||||||
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
||||||
home_widget:
|
|
||||||
:path: ".symlinks/plugins/home_widget/ios"
|
|
||||||
open_filex:
|
open_filex:
|
||||||
:path: ".symlinks/plugins/open_filex/ios"
|
: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:
|
SPEC CHECKSUMS:
|
||||||
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||||
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
||||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
|
||||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||||
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
||||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
|
||||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.17.0
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -12,14 +12,18 @@
|
|||||||
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
|
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.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 */; };
|
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 */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
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, ); }; };
|
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 */; };
|
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
|
||||||
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -37,6 +41,13 @@
|
|||||||
remoteGlobalIDString = AA0101010000000011111111;
|
remoteGlobalIDString = AA0101010000000011111111;
|
||||||
remoteInfo = TimetableWidgetExtension;
|
remoteInfo = TimetableWidgetExtension;
|
||||||
};
|
};
|
||||||
|
AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = AEDC710FEBFF2CC736D88AB2;
|
||||||
|
remoteInfo = NotificationServiceExtension;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -48,6 +59,7 @@
|
|||||||
files = (
|
files = (
|
||||||
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
|
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
|
||||||
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
|
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
|
||||||
|
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
name = "Embed Foundation Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -65,17 +77,24 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||||
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
|
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PEM.swift; sourceTree = "<group>"; };
|
||||||
|
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
|
||||||
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
|
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; 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>"; };
|
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; };
|
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>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -94,10 +113,12 @@
|
|||||||
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -169,6 +190,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
D2447D92E9CCD96B3292B17B /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -185,15 +214,35 @@
|
|||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
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 */ = {
|
731388A08E3B330B216381D0 /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
|
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
|
||||||
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
|
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
|
||||||
|
80D9B9919D3D7CCA2A80C8C5 /* iOS */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
80D9B9919D3D7CCA2A80C8C5 /* iOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F5B421EAF56B77B775E58E92 /* Foundation.framework */,
|
||||||
|
);
|
||||||
|
name = iOS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -208,6 +257,9 @@
|
|||||||
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
|
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
|
||||||
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
|
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
|
||||||
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
|
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
|
||||||
|
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */,
|
||||||
|
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */,
|
||||||
|
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */,
|
||||||
);
|
);
|
||||||
name = Flutter;
|
name = Flutter;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -222,6 +274,7 @@
|
|||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
345F4BD4143471FDA71626DE /* Pods */,
|
345F4BD4143471FDA71626DE /* Pods */,
|
||||||
731388A08E3B330B216381D0 /* Frameworks */,
|
731388A08E3B330B216381D0 /* Frameworks */,
|
||||||
|
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -231,6 +284,7 @@
|
|||||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
|
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
|
||||||
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
|
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
|
||||||
|
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -278,9 +332,6 @@
|
|||||||
productType = "com.apple.product-type.app-extension";
|
productType = "com.apple.product-type.app-extension";
|
||||||
};
|
};
|
||||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
packageProductDependencies = (
|
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
|
||||||
);
|
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
@@ -299,8 +350,12 @@
|
|||||||
dependencies = (
|
dependencies = (
|
||||||
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
|
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
|
||||||
AA0101090000000011111111 /* PBXTargetDependency */,
|
AA0101090000000011111111 /* PBXTargetDependency */,
|
||||||
|
43763BD5552A36CA28890DFA /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
|
packageProductDependencies = (
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
);
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
@@ -325,13 +380,27 @@
|
|||||||
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
|
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
|
||||||
productType = "com.apple.product-type.app-extension";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
97C146E61CF9000F007C117D /* Project object */ = {
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
packageReferences = (
|
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
|
||||||
);
|
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
@@ -360,6 +429,9 @@
|
|||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
mainGroup = 97C146E51CF9000F007C117D;
|
||||||
|
packageReferences = (
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||||
|
);
|
||||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
@@ -367,11 +439,19 @@
|
|||||||
97C146ED1CF9000F007C117D /* Runner */,
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
3321F8042FB1C00C0011C712 /* Share Extension */,
|
3321F8042FB1C00C0011C712 /* Share Extension */,
|
||||||
AA0101010000000011111111 /* TimetableWidgetExtension */,
|
AA0101010000000011111111 /* TimetableWidgetExtension */,
|
||||||
|
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
239068341DC6E6B36193EC96 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
3321F8032FB1C00C0011C712 /* Resources */ = {
|
3321F8032FB1C00C0011C712 /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -520,6 +600,15 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
DF8C1170E7DF96711030EAC0 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */,
|
||||||
|
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
@@ -528,6 +617,12 @@
|
|||||||
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
|
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
|
||||||
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
|
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
43763BD5552A36CA28890DFA /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
name = NotificationServiceExtension;
|
||||||
|
target = AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */;
|
||||||
|
targetProxy = AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
AA0101090000000011111111 /* PBXTargetDependency */ = {
|
AA0101090000000011111111 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
|
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
|
||||||
@@ -929,6 +1024,29 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */ = {
|
AA01010A0000000011111111 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
|
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
|
||||||
@@ -1051,6 +1169,54 @@
|
|||||||
};
|
};
|
||||||
name = Profile;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -1094,13 +1260,25 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
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 */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/app-check.git",
|
"location" : "https://github.com/google/app-check.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
|
"revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a",
|
||||||
"version" : "11.2.0"
|
"version" : "11.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60",
|
"revision" : "27eaab3918e0bf78711cf1abf240577176326432",
|
||||||
"version" : "12.13.0"
|
"version" : "12.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf",
|
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
|
||||||
"version" : "3.5.0"
|
"version" : "3.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604",
|
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
|
||||||
"version" : "12.13.0"
|
"version" : "12.19.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -77,8 +77,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleDataTransport.git",
|
"location" : "https://github.com/google/GoogleDataTransport.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
|
"revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974",
|
||||||
"version" : "10.1.0"
|
"version" : "10.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
|
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
|
||||||
"version" : "8.1.0"
|
"version" : "8.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -104,8 +104,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
|
"revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc",
|
||||||
"version" : "5.3.0"
|
"version" : "5.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -122,8 +122,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/leveldb.git",
|
"location" : "https://github.com/firebase/leveldb.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
|
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
|
||||||
"version" : "1.22.2"
|
"version" : "1.22.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -131,8 +131,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/nanopb.git",
|
"location" : "https://github.com/firebase/nanopb.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
|
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
|
||||||
"version" : "2.30910.0"
|
"version" : "2.30910.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -140,8 +140,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/promises.git",
|
"location" : "https://github.com/google/promises.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
|
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
|
||||||
"version" : "2.4.0"
|
"version" : "2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -50,8 +50,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
|
"revision" : "27eaab3918e0bf78711cf1abf240577176326432",
|
||||||
"version" : "12.15.0"
|
"version" : "12.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
|
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
|
||||||
"version" : "3.6.0"
|
"version" : "3.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
|
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
|
||||||
"version" : "12.15.0"
|
"version" : "12.19.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
|
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
|
||||||
"version" : "8.1.1"
|
"version" : "8.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import workmanager_apple
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
@@ -12,7 +13,7 @@ import UserNotifications
|
|||||||
private let markReadActionId = "TALK_MARK_READ"
|
private let markReadActionId = "TALK_MARK_READ"
|
||||||
|
|
||||||
// Shared (App Group) keychain — same group as the NSE and the Dart side.
|
// 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 usernameAccount = "nextcloud_username"
|
||||||
private let appPasswordAccount = "nextcloud_app_password"
|
private let appPasswordAccount = "nextcloud_app_password"
|
||||||
private let baseUrlAccount = "nextcloud_base_url"
|
private let baseUrlAccount = "nextcloud_base_url"
|
||||||
@@ -23,6 +24,19 @@ import UserNotifications
|
|||||||
) -> Bool {
|
) -> Bool {
|
||||||
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
registerTalkCategory()
|
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
|
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
|
||||||
// forwards these callbacks to the plugins (firebase_messaging,
|
// forwards these callbacks to the plugins (firebase_messaging,
|
||||||
// flutter_local_notifications). We route Talk actions natively here — the
|
// flutter_local_notifications). We route Talk actions natively here — the
|
||||||
|
|||||||
+86
-82
@@ -1,90 +1,94 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>AppGroupId</key>
|
<key>AppGroupId</key>
|
||||||
<string>$(CUSTOM_GROUP_ID)</string>
|
<string>$(CUSTOM_GROUP_ID)</string>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
<true/>
|
<array>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<string>eu.mhsl.marianum.widget.refresh</string>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
</array>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<string>Marianum Fulda</string>
|
<true/>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>Marianum Fulda</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>6.0</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>client</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>APPL</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>client</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>????</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<array>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
<dict>
|
<key>CFBundleSignature</key>
|
||||||
<key>CFBundleTypeRole</key>
|
<string>????</string>
|
||||||
<string>Editor</string>
|
<key>CFBundleURLTypes</key>
|
||||||
<key>CFBundleURLSchemes</key>
|
<array>
|
||||||
<array>
|
|
||||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
|
||||||
<key>LSRequiresIPhoneOS</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSCameraUsageDescription</key>
|
|
||||||
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
|
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
|
|
||||||
<key>UIApplicationSceneManifest</key>
|
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>CFBundleTypeRole</key>
|
||||||
<false/>
|
<string>Editor</string>
|
||||||
<key>UISceneConfigurations</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<dict>
|
<array>
|
||||||
<key>UIWindowSceneSessionRoleApplication</key>
|
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<array>
|
</array>
|
||||||
<dict>
|
|
||||||
<key>UISceneClassName</key>
|
|
||||||
<string>UIWindowScene</string>
|
|
||||||
<key>UISceneConfigurationName</key>
|
|
||||||
<string>flutter</string>
|
|
||||||
<key>UISceneDelegateClassName</key>
|
|
||||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
|
||||||
<key>UISceneStoryboardFile</key>
|
|
||||||
<string>Main</string>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
</array>
|
||||||
<true/>
|
<key>CFBundleVersion</key>
|
||||||
<key>UIBackgroundModes</key>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<array>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<string>fetch</string>
|
<true/>
|
||||||
<string>remote-notification</string>
|
<key>NSCameraUsageDescription</key>
|
||||||
</array>
|
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>LaunchScreen</string>
|
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<string>Main</string>
|
<dict>
|
||||||
<key>UIStatusBarHidden</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
<false/>
|
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
|
||||||
</array>
|
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIMainStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
<key>UIStatusBarHidden</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
</array>
|
||||||
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -13,21 +13,44 @@ class SceneDelegate: FlutterSceneDelegate {
|
|||||||
options connectionOptions: UIScene.ConnectionOptions
|
options connectionOptions: UIScene.ConnectionOptions
|
||||||
) {
|
) {
|
||||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||||
for context in connectionOptions.urlContexts {
|
var handledShare = false
|
||||||
_ = SwiftReceiveSharingIntentPlugin.instance.application(
|
for context in connectionOptions.urlContexts where isShareUrl(context.url) {
|
||||||
|
_ = ReceiveSharingIntentPlugin.instance.application(
|
||||||
UIApplication.shared,
|
UIApplication.shared,
|
||||||
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
|
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
|
||||||
)
|
)
|
||||||
|
handledShare = true
|
||||||
}
|
}
|
||||||
|
if handledShare { clearSharedPayload() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||||
for context in URLContexts {
|
let shares = URLContexts.filter { isShareUrl($0.url) }
|
||||||
_ = SwiftReceiveSharingIntentPlugin.instance.application(
|
for context in shares {
|
||||||
|
_ = ReceiveSharingIntentPlugin.instance.application(
|
||||||
UIApplication.shared,
|
UIApplication.shared,
|
||||||
open: context.url,
|
open: context.url,
|
||||||
options: [:]
|
options: [:]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if !shares.isEmpty { clearSharedPayload() }
|
||||||
|
let others = URLContexts.subtracting(shares)
|
||||||
|
if !others.isEmpty { super.scene(scene, openURLContexts: others) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isShareUrl(_ url: URL) -> Bool {
|
||||||
|
ReceiveSharingIntentPlugin.instance.hasMatchingSchemePrefix(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plugin keeps the payload in memory once read but never removes it
|
||||||
|
// from the App Group defaults. The Share Extension opens the app twice per
|
||||||
|
// share and a scene reconnect replays the URL, so every later delivery
|
||||||
|
// would publish the finished share again.
|
||||||
|
private func clearSharedPayload() {
|
||||||
|
let groupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String
|
||||||
|
let defaults = UserDefaults(
|
||||||
|
suiteName: groupId ?? "group.\(Bundle.main.bundleIdentifier ?? "")")
|
||||||
|
defaults?.removeObject(forKey: "ShareKey")
|
||||||
|
defaults?.removeObject(forKey: "ShareMessageKey")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import UniformTypeIdentifiers
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
// Datenmodell muss byte-für-byte zu dem passen, was
|
// 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 {
|
private enum SharedMediaType: String, Codable {
|
||||||
case image, video, text, file, url
|
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
|
guard let container = FileManager.default
|
||||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
|
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
|
let root = container.appendingPathComponent("share_intent", isDirectory: true)
|
||||||
let dst = container.appendingPathComponent(name)
|
sweepStaleShares(in: root)
|
||||||
|
let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Shares, deren App-Flow nie abgeschlossen wurde (Prozess beendet o. ä.),
|
||||||
|
// räumt sonst niemand weg — der App-Group-Container wird nie geleert.
|
||||||
|
private func sweepStaleShares(in root: URL) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard let entries = try? fm.contentsOfDirectory(
|
||||||
|
at: root, includingPropertiesForKeys: [.creationDateKey]) else { return }
|
||||||
|
let cutoff = Date().addingTimeInterval(-24 * 60 * 60)
|
||||||
|
for entry in entries {
|
||||||
|
let created = (try? entry.resourceValues(forKeys: [.creationDateKey]))?.creationDate
|
||||||
|
if let created, created < cutoff { try? fm.removeItem(at: entry) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyIntoAppGroup(src: URL) -> URL? {
|
||||||
|
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 {
|
do {
|
||||||
if FileManager.default.fileExists(atPath: dst.path) {
|
|
||||||
try FileManager.default.removeItem(at: dst)
|
|
||||||
}
|
|
||||||
try FileManager.default.copyItem(at: src, to: dst)
|
try FileManager.default.copyItem(at: src, to: dst)
|
||||||
return dst
|
return dst
|
||||||
} catch {
|
} catch {
|
||||||
@@ -171,10 +208,9 @@ final class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func writePng(image: UIImage) -> URL? {
|
private func writePng(image: UIImage) -> URL? {
|
||||||
guard let container = FileManager.default
|
guard let data = image.pngData(),
|
||||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId),
|
let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
|
||||||
let data = image.pngData() else { return nil }
|
let dst = dir.appendingPathComponent("\(UUID().uuidString).png")
|
||||||
let dst = container.appendingPathComponent("\(UUID().uuidString).png")
|
|
||||||
return (try? data.write(to: dst)) != nil ? dst : nil
|
return (try? data.write(to: dst)) != nil ? dst : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
// Layout constants — must mirror WidgetRenderer.kt on Android, otherwise
|
|
||||||
// the platforms drift apart on the same widget size.
|
|
||||||
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
|
|
||||||
let MIN_HOUR_HEIGHT: CGFloat = 18
|
|
||||||
let MAX_HOUR_HEIGHT: CGFloat = 72
|
|
||||||
let MIN_BLOCK_HEIGHT: CGFloat = 16
|
|
||||||
let LESSON_GAP: CGFloat = 1.5
|
|
||||||
|
|
||||||
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
|
|
||||||
guard !periods.isEmpty else { return CGFloat(realMin) }
|
|
||||||
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
|
|
||||||
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
|
|
||||||
}
|
|
||||||
let first = periods.first!
|
|
||||||
if realMin < first.startMinutes {
|
|
||||||
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
|
|
||||||
}
|
|
||||||
let last = periods.last!
|
|
||||||
if realMin > last.endMinutes {
|
|
||||||
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
|
|
||||||
}
|
|
||||||
var prev = first
|
|
||||||
for i in 1..<periods.count {
|
|
||||||
let curr = periods[i]
|
|
||||||
if realMin > prev.endMinutes && realMin < curr.startMinutes {
|
|
||||||
let gap = curr.startMinutes - prev.endMinutes
|
|
||||||
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
|
|
||||||
if gap > 0 {
|
|
||||||
return CGFloat(prev.virtualEndMinutes) +
|
|
||||||
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
|
|
||||||
}
|
|
||||||
return CGFloat(curr.virtualStartMinutes)
|
|
||||||
}
|
|
||||||
prev = curr
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Below these tile heights the secondary stack collapses progressively:
|
|
||||||
// shorter than ROOM_MIN drops everything, between ROOM_MIN and
|
|
||||||
// TEACHER_SEPARATE_MIN keeps the room but drops the teacher.
|
|
||||||
let BLOCK_SHOW_ROOM_MIN: CGFloat = 13
|
|
||||||
let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 20
|
|
||||||
|
|
||||||
let MIN_SUBJECT_FONT: CGFloat = 9
|
|
||||||
let MAX_SUBJECT_FONT: CGFloat = 14
|
|
||||||
let MIN_SECONDARY_FONT: CGFloat = 7
|
|
||||||
|
|
||||||
func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat {
|
|
||||||
let t = max(0, min(1, (hourHeight - MIN_HOUR_HEIGHT) / (MAX_HOUR_HEIGHT - MIN_HOUR_HEIGHT)))
|
|
||||||
return MIN_SUBJECT_FONT + t * (MAX_SUBJECT_FONT - MIN_SUBJECT_FONT)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TimetableDayView: View {
|
struct TimetableDayView: View {
|
||||||
let entry: TimetableEntry
|
let entry: TimetableEntry
|
||||||
@Environment(\.widgetPalette) private var palette
|
@Environment(\.widgetPalette) private var palette
|
||||||
@@ -73,7 +20,7 @@ struct TimetableDayView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func content(data: WidgetTimetableData) -> some View {
|
private func content(data: WidgetTimetableData) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) {
|
||||||
header(data: data)
|
header(data: data)
|
||||||
if data.isHoliday {
|
if data.isHoliday {
|
||||||
emptyState(text: data.holidayName ?? "Ferien")
|
emptyState(text: data.holidayName ?? "Ferien")
|
||||||
@@ -81,37 +28,41 @@ struct TimetableDayView: View {
|
|||||||
emptyState(text: "Keine Stunden")
|
emptyState(text: "Keine Stunden")
|
||||||
} else {
|
} else {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
|
let periods = trimTrailingPeriods(data.periods, lessons: data.lessons)
|
||||||
TimeGridView(
|
HStack(alignment: .top, spacing: 0) {
|
||||||
lessons: data.lessons,
|
PeriodRuler(
|
||||||
periods: data.periods,
|
periods: periods,
|
||||||
hourHeight: max(
|
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
|
||||||
MIN_HOUR_HEIGHT,
|
)
|
||||||
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
|
Rectangle()
|
||||||
),
|
.fill(palette.divider)
|
||||||
showRoom: true,
|
.frame(width: 1)
|
||||||
showTeacher: true,
|
TimeGridView(
|
||||||
showTimeLabels: true
|
lessons: data.lessons,
|
||||||
)
|
periods: periods,
|
||||||
|
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func header(data: WidgetTimetableData) -> some View {
|
private func header(data: WidgetTimetableData) -> some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 6) {
|
||||||
Text(dayLabel(for: data.anchorDate))
|
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.65)
|
||||||
Spacer(minLength: 4)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.fixedSize()
|
||||||
}
|
}
|
||||||
|
.frame(height: TITLE_ROW_HEIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func emptyState(text: String) -> some View {
|
private func emptyState(text: String) -> some View {
|
||||||
@@ -139,54 +90,23 @@ struct TimetableDayView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TimeGridView: View {
|
/// Mirrors the in-app _PeriodLabel: period number centred, start time above,
|
||||||
let lessons: [WidgetLesson]
|
/// end time below, a cup in the breaks.
|
||||||
|
struct PeriodRuler: View {
|
||||||
let periods: [WidgetPeriod]
|
let periods: [WidgetPeriod]
|
||||||
let hourHeight: CGFloat
|
let hourHeight: CGFloat
|
||||||
let showRoom: Bool
|
|
||||||
let showTeacher: Bool
|
|
||||||
let showTimeLabels: Bool
|
|
||||||
/// Week-widget passes 3 for narrow columns; day-widget keeps 7.
|
|
||||||
var horizontalPadding: CGFloat = 7
|
|
||||||
|
|
||||||
@Environment(\.widgetPalette) private var palette
|
@Environment(\.widgetPalette) private var palette
|
||||||
|
|
||||||
private var totalVirtualMinutes: Int {
|
|
||||||
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
|
|
||||||
}
|
|
||||||
|
|
||||||
private var totalHeight: CGFloat {
|
private var totalHeight: CGFloat {
|
||||||
CGFloat(totalVirtualMinutes) * hourHeight / 60.0
|
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Below this per-hour height the two-line label collapses to a single
|
|
||||||
/// period number — time + number overlap otherwise.
|
|
||||||
private var compactLabels: Bool { hourHeight < 26 }
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
// Every child has layout-y 0 (`.offset` only shifts the rendering),
|
||||||
if showTimeLabels {
|
// so both the ZStack and its frame need `.top` — the default
|
||||||
// 28pt matches the Week widget's time-label column so the
|
// `.center` would anchor the whole ruler at the column's midpoint.
|
||||||
// visual rhythm is identical across both widgets.
|
ZStack(alignment: .top) {
|
||||||
timeLabelsColumn
|
|
||||||
.frame(width: 28, alignment: .topTrailing)
|
|
||||||
Rectangle()
|
|
||||||
.fill(palette.divider)
|
|
||||||
.frame(width: 1)
|
|
||||||
}
|
|
||||||
ZStack(alignment: .top) {
|
|
||||||
gridLines
|
|
||||||
breakBlocks
|
|
||||||
ForEach(lessons.indices, id: \.self) { idx in
|
|
||||||
lessonBlock(lessons[idx])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var timeLabelsColumn: some View {
|
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(palette.divider)
|
.fill(palette.divider)
|
||||||
@@ -194,39 +114,91 @@ struct TimeGridView: View {
|
|||||||
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
|
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
|
||||||
}
|
}
|
||||||
ForEach(periods, id: \.startMinutes) { period in
|
ForEach(periods, id: \.startMinutes) { period in
|
||||||
VStack(alignment: .trailing, spacing: -2) {
|
cell(period)
|
||||||
if compactLabels {
|
}
|
||||||
Text("\(period.name).")
|
ForEach(periodBreaks(periods), id: \.self) { pause in
|
||||||
.font(.system(size: 9, weight: .bold))
|
let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
|
||||||
.foregroundStyle(palette.textPrimary)
|
if height >= BREAK_ICON_MIN_HEIGHT {
|
||||||
.lineLimit(1)
|
Image(systemName: "cup.and.saucer")
|
||||||
} else {
|
.font(.system(size: 8))
|
||||||
Text(formatHm(period.startMinutes))
|
.foregroundStyle(palette.textSecondary)
|
||||||
.font(.system(size: 9))
|
.frame(width: RULER_WIDTH, height: height)
|
||||||
.foregroundStyle(palette.textPrimary)
|
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
|
||||||
.lineLimit(1)
|
|
||||||
Text("\(period.name).")
|
|
||||||
.font(.system(size: 7, weight: .bold))
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.trailing, 4)
|
|
||||||
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: totalHeight, alignment: .topTrailing)
|
.frame(width: RULER_WIDTH, height: totalHeight, alignment: .top)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cell(_ period: WidgetPeriod) -> some View {
|
||||||
|
let height = CGFloat(period.virtualEndMinutes - period.virtualStartMinutes) * hourHeight / 60.0
|
||||||
|
let timeFont = max(6, min(8.5, height * 0.19))
|
||||||
|
return ZStack {
|
||||||
|
Text(period.name)
|
||||||
|
.font(.system(size: max(8, min(13, height * 0.3)), weight: .medium))
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
if height >= RULER_TIMES_MIN_CELL {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text(formatHm(period.startMinutes))
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(formatHm(period.endMinutes))
|
||||||
|
}
|
||||||
|
.font(.system(size: timeFont))
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: RULER_WIDTH, height: height)
|
||||||
|
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatHm(_ minutes: Int) -> String {
|
||||||
|
String(format: "%02d:%02d", minutes / 60, minutes % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One day column: grid lines, break bands and the lesson tiles. Shared by
|
||||||
|
/// the day widget and each of the week widget's five columns.
|
||||||
|
struct TimeGridView: View {
|
||||||
|
let lessons: [WidgetLesson]
|
||||||
|
let periods: [WidgetPeriod]
|
||||||
|
let hourHeight: CGFloat
|
||||||
|
var holiday: WidgetDayInfo? = nil
|
||||||
|
var isToday: Bool = false
|
||||||
|
|
||||||
|
@Environment(\.widgetPalette) private var palette
|
||||||
|
|
||||||
|
private var totalHeight: CGFloat {
|
||||||
|
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let metrics = TileMetrics(periods: periods, hourHeight: hourHeight)
|
||||||
|
let innerWidth = geo.size.width - 2 * (TILE_MARGIN_H + TILE_PADDING_H)
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
if let holiday {
|
||||||
|
holidayColumn(holiday)
|
||||||
|
} else {
|
||||||
|
if isToday {
|
||||||
|
palette.todayTint.frame(height: totalHeight)
|
||||||
|
}
|
||||||
|
gridLines
|
||||||
|
breakBlocks
|
||||||
|
}
|
||||||
|
ForEach(lessons.indices, id: \.self) { idx in
|
||||||
|
lessonBlock(lessons[idx], metrics: metrics, innerWidth: innerWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: geo.size.width, height: totalHeight, alignment: .top)
|
||||||
|
}
|
||||||
|
.frame(height: totalHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var gridLines: some View {
|
private var gridLines: some View {
|
||||||
// `.frame(height: totalHeight, alignment: .top)` — without the
|
// `.top` on both the ZStack and the frame — see PeriodRuler.
|
||||||
// explicit `.top`, SwiftUI's frame modifier defaults to `.center`.
|
|
||||||
// Every Rectangle here has layout-y=0 (the `.offset()`s shift the
|
|
||||||
// rendered position only, not layout), so the inner ZStack's
|
|
||||||
// natural height is just 1pt; a 1pt ZStack centred in a 300pt
|
|
||||||
// frame anchors at the middle, and every line then renders
|
|
||||||
// relative to that midpoint — the grid visually starts halfway
|
|
||||||
// down the column and the later periods clip past the bottom.
|
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@@ -239,170 +211,200 @@ struct TimeGridView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var breakBlocks: some View {
|
private var breakBlocks: some View {
|
||||||
// Same `.center`-vs-`.top` bug as gridLines — see comment above.
|
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
ForEach(0..<max(0, periods.count - 1), id: \.self) { i in
|
ForEach(periodBreaks(periods), id: \.self) { pause in
|
||||||
let curr = periods[i]
|
let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
|
||||||
let next = periods[i + 1]
|
ZStack {
|
||||||
let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes
|
palette.breakBlock
|
||||||
if virtualGap > 0 {
|
if height >= BREAK_ICON_MIN_HEIGHT {
|
||||||
Rectangle()
|
Image(systemName: "fork.knife")
|
||||||
.fill(palette.breakBlock)
|
.font(.system(size: 9))
|
||||||
.frame(height: CGFloat(virtualGap) * hourHeight / 60.0)
|
.foregroundStyle(palette.accent)
|
||||||
.padding(.horizontal, 1)
|
}
|
||||||
.offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0)
|
|
||||||
}
|
}
|
||||||
|
.frame(height: height)
|
||||||
|
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: totalHeight, alignment: .top)
|
.frame(height: totalHeight, alignment: .top)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatHm(_ minutes: Int) -> String {
|
/// Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
|
||||||
String(format: "%02d:%02d", minutes / 60, minutes % 60)
|
/// holiday name rotated along the column.
|
||||||
|
private func holidayColumn(_ holiday: WidgetDayInfo) -> some View {
|
||||||
|
let nameLength = max(40, totalHeight - 2 * HOLIDAY_NAME_INSET)
|
||||||
|
return ZStack {
|
||||||
|
palette.holidayTint
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text("Schulfrei")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.6)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
|
// The outer frame carries the rotated footprint: rotationEffect
|
||||||
|
// alone leaves the layout box at its unrotated size.
|
||||||
|
Text(holiday.holidayName ?? "")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.kerning(1)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.6)
|
||||||
|
.frame(width: nameLength, height: 14)
|
||||||
|
.rotationEffect(.degrees(90))
|
||||||
|
.frame(width: 14, height: nameLength)
|
||||||
|
}
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.frame(height: totalHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func lessonBlock(_ lesson: WidgetLesson) -> some View {
|
private func lessonBlock(
|
||||||
let cal = Calendar.current
|
_ lesson: WidgetLesson,
|
||||||
let comps = cal.dateComponents([.hour, .minute], from: lesson.start)
|
metrics: TileMetrics,
|
||||||
let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
innerWidth: CGFloat
|
||||||
|
) -> some View {
|
||||||
|
let startMinutes = minutesOfDay(lesson.start)
|
||||||
let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60))
|
let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60))
|
||||||
let virtualStart = realMinutesToVirtual(startMinutes, periods: periods)
|
let virtualStart = realMinutesToVirtual(startMinutes, periods: periods)
|
||||||
let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods)
|
let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods)
|
||||||
|
|
||||||
if virtualEnd > virtualStart {
|
if virtualEnd > virtualStart {
|
||||||
|
// Half the gap above + half below so the grid line under the
|
||||||
|
// tile stays visible.
|
||||||
let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2
|
let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2
|
||||||
let height = max(
|
let height = max(
|
||||||
MIN_BLOCK_HEIGHT,
|
MIN_BLOCK_HEIGHT,
|
||||||
(virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP
|
(virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP
|
||||||
)
|
)
|
||||||
let subjectSize = subjectFont(forHourHeight: hourHeight)
|
LessonTile(
|
||||||
let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2)
|
lesson: lesson,
|
||||||
let room = lesson.room
|
height: height,
|
||||||
let teacher = lesson.teacher ?? lesson.originalTeacher
|
metrics: metrics,
|
||||||
let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false)
|
layout: TileTextLayout(
|
||||||
HStack(alignment: .top, spacing: 4) {
|
lesson: lesson,
|
||||||
Text(subjectLabel(lesson))
|
innerHeight: height - 2 * TILE_PADDING_V,
|
||||||
.font(.system(size: subjectSize, weight: .semibold))
|
innerWidth: innerWidth,
|
||||||
.foregroundStyle(.white)
|
metrics: metrics
|
||||||
.lineLimit(hasSecondary ? 1 : 2)
|
)
|
||||||
.minimumScaleFactor(0.5)
|
)
|
||||||
if hasSecondary {
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
VStack(alignment: .trailing, spacing: -1) {
|
|
||||||
if showRoom && height >= BLOCK_SHOW_ROOM_MIN {
|
|
||||||
if let room, !room.isEmpty {
|
|
||||||
Text(room)
|
|
||||||
.font(.system(size: secondarySize))
|
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
}
|
|
||||||
if showTeacher,
|
|
||||||
height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN,
|
|
||||||
let teacher,
|
|
||||||
!teacher.isEmpty {
|
|
||||||
Text(teacher)
|
|
||||||
.font(.system(size: secondarySize))
|
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, horizontalPadding)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
||||||
.frame(height: height, alignment: .topLeading)
|
|
||||||
.background(blockColor(lesson))
|
|
||||||
.cornerRadius(6)
|
|
||||||
.overlay(alignment: .bottomLeading) {
|
|
||||||
// Separate fixed-size badge so the +N hint stays readable
|
|
||||||
// when the subject autoshrinks on narrow tiles.
|
|
||||||
if let count = lesson.siblingCount, count > 0 {
|
|
||||||
Text("+\(count)")
|
|
||||||
.font(.system(size: 12, weight: .bold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.padding(.leading, horizontalPadding)
|
|
||||||
.padding(.bottom, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.overlay {
|
|
||||||
// CrossPainter parity: clip cross to the rounded shape so
|
|
||||||
// the diagonals don't bleed past the corners.
|
|
||||||
if lesson.status == .cancelled {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.stroke(Color.red.opacity(0.78), lineWidth: 1.5)
|
|
||||||
GeometryReader { geo in
|
|
||||||
Path { p in
|
|
||||||
p.move(to: .zero)
|
|
||||||
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
|
|
||||||
p.move(to: CGPoint(x: geo.size.width, y: 0))
|
|
||||||
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
|
|
||||||
}
|
|
||||||
.stroke(Color.red.opacity(0.78), lineWidth: 3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, showRoom ? 2 : 1)
|
|
||||||
.offset(y: top)
|
.offset(y: top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func subjectLabel(_ lesson: WidgetLesson) -> String {
|
/// Mirrors the in-app AppointmentTile: title on top, room and teacher
|
||||||
!lesson.subjectShort.isEmpty
|
/// stacked below, each line shrinking on its own to fit the tile width.
|
||||||
? lesson.subjectShort
|
private struct LessonTile: View {
|
||||||
: (lesson.subjectLong ?? "—")
|
let lesson: WidgetLesson
|
||||||
}
|
let height: CGFloat
|
||||||
|
let metrics: TileMetrics
|
||||||
|
let layout: TileTextLayout
|
||||||
|
|
||||||
/// Mirrors lesson_color.dart + custom_event_colors.dart so the widget
|
private static let cancelledRed =
|
||||||
/// matches the in-app calendar exactly.
|
Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255)
|
||||||
private func blockColor(_ lesson: WidgetLesson) -> Color {
|
|
||||||
if lesson.status == .event, let custom = lesson.customColor {
|
var body: some View {
|
||||||
switch custom {
|
let shape = RoundedRectangle(cornerRadius: TILE_CORNER_RADIUS)
|
||||||
case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
|
titleRow
|
||||||
case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0)
|
ForEach(Array(layout.body.enumerated()), id: \.offset) { index, line in
|
||||||
case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0)
|
Text(line)
|
||||||
default: break
|
.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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch lesson.status {
|
.foregroundStyle(.white)
|
||||||
// `.ongoing` deliberately collapses into the regular red — the
|
.padding(.horizontal, TILE_PADDING_H)
|
||||||
// widget timeline only ticks every ~30min, so highlighting the
|
.padding(.vertical, TILE_PADDING_V)
|
||||||
// "current" lesson would be stale most of the day and misleads
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
// more than it helps.
|
.frame(height: height, alignment: .topLeading)
|
||||||
case .regular, .past, .ongoing:
|
.background(lesson.tileColor)
|
||||||
return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
|
.clipShape(shape)
|
||||||
case .cancelled: return .black
|
.overlay(alignment: .bottomTrailing) {
|
||||||
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
|
if let count = lesson.siblingCount, count > 0 {
|
||||||
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
|
Text("+\(count)")
|
||||||
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.trailing, TILE_PADDING_H)
|
||||||
|
.padding(.bottom, TILE_PADDING_V)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
if lesson.status == .cancelled {
|
||||||
|
ZStack {
|
||||||
|
shape.strokeBorder(LessonTile.cancelledRed, lineWidth: 2)
|
||||||
|
GeometryReader { geo in
|
||||||
|
Path { p in
|
||||||
|
p.move(to: .zero)
|
||||||
|
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
|
||||||
|
p.move(to: CGPoint(x: geo.size.width, y: 0))
|
||||||
|
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
|
||||||
|
}
|
||||||
|
.stroke(LessonTile.cancelledRed, lineWidth: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(shape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, TILE_MARGIN_H)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titleRow: some View {
|
||||||
|
HStack(alignment: .center, spacing: 3) {
|
||||||
|
Text(layout.title)
|
||||||
|
.font(.system(size: layout.titleFont, weight: .medium))
|
||||||
|
.lineLimit(layout.titleLines)
|
||||||
|
.minimumScaleFactor(min(1, MIN_TITLE_FONT / layout.titleFont))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
if let inline = layout.inline {
|
||||||
|
Text(inline)
|
||||||
|
.font(.system(size: metrics.bodyFont))
|
||||||
|
.lineLimit(1)
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: layout.titleBox)
|
||||||
|
.padding(.trailing, badgeInset(line: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func badgeInset(line: Int) -> CGFloat {
|
||||||
|
layout.badgeLine == line ? BADGE_WIDTH : 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Period boundaries deduped: adjacent periods share a line, periods on
|
/// Fixed-locale formatters cached once — DateFormatter setup is the
|
||||||
/// either side of a break get their own (bracketing the break block).
|
/// expensive part and the multi-entry timelines render up to ~8 entries per
|
||||||
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
|
/// reload. Only touched from WidgetKit's archival rendering, so the shared
|
||||||
var seen = Set<Int>()
|
/// instances are safe.
|
||||||
var result: [Int] = []
|
enum WidgetDateFormatters {
|
||||||
for p in periods {
|
static let shortDate = make("dd.MM.")
|
||||||
for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
|
static let weekdayShort = make("EE")
|
||||||
if seen.insert(v).inserted { result.append(v) }
|
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 cal = Calendar.current
|
||||||
let today = cal.startOfDay(for: Date())
|
let today = cal.startOfDay(for: now)
|
||||||
let anchor = cal.startOfDay(for: date)
|
let anchor = cal.startOfDay(for: date)
|
||||||
if anchor == today {
|
if anchor == today {
|
||||||
return "Heute · \(shortDate(date))"
|
return "Heute · \(shortDate(date))"
|
||||||
@@ -410,35 +412,23 @@ func dayLabel(for date: Date) -> String {
|
|||||||
if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
|
if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
|
||||||
return "Morgen · \(shortDate(date))"
|
return "Morgen · \(shortDate(date))"
|
||||||
}
|
}
|
||||||
let formatter = DateFormatter()
|
return WidgetDateFormatters.weekdayDate.string(from: date)
|
||||||
formatter.locale = Locale(identifier: "de_DE")
|
|
||||||
formatter.dateFormat = "EEEE · dd.MM."
|
|
||||||
return formatter.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func shortDate(_ date: Date) -> String {
|
func shortDate(_ date: Date) -> String {
|
||||||
let f = DateFormatter()
|
WidgetDateFormatters.shortDate.string(from: date)
|
||||||
f.locale = Locale(identifier: "de_DE")
|
|
||||||
f.dateFormat = "dd.MM."
|
|
||||||
return f.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func freshnessLabel(for fetchedAt: Date) -> String {
|
func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
let today = cal.startOfDay(for: Date())
|
let today = cal.startOfDay(for: now)
|
||||||
let fetchedDay = cal.startOfDay(for: fetchedAt)
|
let fetchedDay = cal.startOfDay(for: fetchedAt)
|
||||||
let timeFmt = DateFormatter()
|
|
||||||
timeFmt.locale = Locale(identifier: "de_DE")
|
|
||||||
timeFmt.dateFormat = "HH:mm"
|
|
||||||
if fetchedDay == today {
|
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),
|
if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
|
||||||
fetchedDay == yesterday {
|
fetchedDay == yesterday {
|
||||||
return "gestern \(timeFmt.string(from: fetchedAt))"
|
return "gestern \(WidgetDateFormatters.time.string(from: fetchedAt))"
|
||||||
}
|
}
|
||||||
let dateTimeFmt = DateFormatter()
|
return WidgetDateFormatters.dateTime.string(from: fetchedAt)
|
||||||
dateTimeFmt.locale = Locale(identifier: "de_DE")
|
|
||||||
dateTimeFmt.dateFormat = "dd.MM. HH:mm"
|
|
||||||
return dateTimeFmt.string(from: fetchedAt)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// Layout constants and rules — must mirror WidgetRenderer.kt on Android,
|
||||||
|
// otherwise the platforms drift apart on the same widget size.
|
||||||
|
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
|
||||||
|
let FALLBACK_SLOT_MINUTES = 45
|
||||||
|
|
||||||
|
/// Trailing periods without lessons are cut off so the tiles get more
|
||||||
|
/// height — but never below this many periods, or a short day would blow up
|
||||||
|
/// to a handful of oversized tiles.
|
||||||
|
let MIN_VISIBLE_PERIODS = 6
|
||||||
|
|
||||||
|
let MIN_HOUR_HEIGHT: CGFloat = 18
|
||||||
|
let MAX_HOUR_HEIGHT: CGFloat = 72
|
||||||
|
let MIN_BLOCK_HEIGHT: CGFloat = 16
|
||||||
|
let LESSON_GAP: CGFloat = 1.5
|
||||||
|
|
||||||
|
let TITLE_ROW_HEIGHT: CGFloat = 20
|
||||||
|
let GRID_MARGIN_TOP: CGFloat = 4
|
||||||
|
let WEEK_HEADER_HEIGHT: CGFloat = 32
|
||||||
|
let RULER_WIDTH: CGFloat = 30
|
||||||
|
|
||||||
|
let TILE_MARGIN_H: CGFloat = 1
|
||||||
|
let TILE_PADDING_H: CGFloat = 4
|
||||||
|
let TILE_PADDING_V: CGFloat = 2
|
||||||
|
let TILE_CORNER_RADIUS: CGFloat = 7
|
||||||
|
|
||||||
|
// In-app tile: 15sp title over 10sp body lines. The widget keeps that ratio
|
||||||
|
// and scales both so title + room + teacher fill one period.
|
||||||
|
let TITLE_TO_BODY_RATIO: CGFloat = 1.5
|
||||||
|
let MAX_BODY_FONT: CGFloat = 10
|
||||||
|
let MIN_BODY_FONT: CGFloat = 6.5
|
||||||
|
let MIN_TITLE_FONT: CGFloat = 6.5
|
||||||
|
let MIN_BODY_LINE_FONT: CGFloat = 5.5
|
||||||
|
let LINE_BOX_PER_FONT: CGFloat = 1.2
|
||||||
|
|
||||||
|
/// If stacking room and teacher would push the body font below this, both
|
||||||
|
/// share one line instead so the text stays readable.
|
||||||
|
let STACKED_BODY_MIN_FONT: CGFloat = 7.5
|
||||||
|
|
||||||
|
/// A title that needs to shrink below this to fit on one line wraps onto a
|
||||||
|
/// second line instead, if the tile has the height for it.
|
||||||
|
let TITLE_WRAP_BELOW_FONT: CGFloat = 8
|
||||||
|
|
||||||
|
// Footprint of the "+N" badge in the tile's bottom trailing corner.
|
||||||
|
let BADGE_WIDTH: CGFloat = 16
|
||||||
|
let BADGE_HEIGHT: CGFloat = 11
|
||||||
|
|
||||||
|
let RULER_TIMES_MIN_CELL: CGFloat = 30
|
||||||
|
let BREAK_ICON_MIN_HEIGHT: CGFloat = 12
|
||||||
|
|
||||||
|
/// Space the holiday column keeps free above and below the rotated name for
|
||||||
|
/// the icon and the "Schulfrei" label.
|
||||||
|
let HOLIDAY_NAME_INSET: CGFloat = 44
|
||||||
|
|
||||||
|
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
|
||||||
|
guard !periods.isEmpty else { return CGFloat(realMin) }
|
||||||
|
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
|
||||||
|
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
|
||||||
|
}
|
||||||
|
let first = periods.first!
|
||||||
|
if realMin < first.startMinutes {
|
||||||
|
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
|
||||||
|
}
|
||||||
|
let last = periods.last!
|
||||||
|
if realMin > last.endMinutes {
|
||||||
|
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
|
||||||
|
}
|
||||||
|
var prev = first
|
||||||
|
for i in 1..<periods.count {
|
||||||
|
let curr = periods[i]
|
||||||
|
if realMin > prev.endMinutes && realMin < curr.startMinutes {
|
||||||
|
let gap = curr.startMinutes - prev.endMinutes
|
||||||
|
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
|
||||||
|
if gap > 0 {
|
||||||
|
return CGFloat(prev.virtualEndMinutes) +
|
||||||
|
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
|
||||||
|
}
|
||||||
|
return CGFloat(curr.virtualStartMinutes)
|
||||||
|
}
|
||||||
|
prev = curr
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func minutesOfDay(_ date: Date) -> Int {
|
||||||
|
let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
|
||||||
|
return (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimTrailingPeriods(_ periods: [WidgetPeriod], lessons: [WidgetLesson]) -> [WidgetPeriod] {
|
||||||
|
guard let latestEnd = lessons.map({ minutesOfDay($0.end) }).max() else { return periods }
|
||||||
|
let lastUsed = periods.lastIndex { $0.startMinutes < latestEnd } ?? -1
|
||||||
|
return Array(periods.prefix(max(lastUsed + 1, MIN_VISIBLE_PERIODS)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalVirtualMinutes(_ periods: [WidgetPeriod]) -> Int {
|
||||||
|
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveHourHeight(forGridHeight height: CGFloat, periods: [WidgetPeriod]) -> CGFloat {
|
||||||
|
let virtualHours = max(CGFloat(totalVirtualMinutes(periods)) / 60, 1)
|
||||||
|
return max(MIN_HOUR_HEIGHT, min(MAX_HOUR_HEIGHT, height / virtualHours))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Period boundaries deduped: adjacent periods share a line, periods on
|
||||||
|
/// either side of a break get their own (bracketing the break block).
|
||||||
|
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
|
||||||
|
var seen = Set<Int>()
|
||||||
|
var result: [Int] = []
|
||||||
|
for p in periods {
|
||||||
|
for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
|
||||||
|
if seen.insert(v).inserted { result.append(v) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PeriodBreak: Hashable {
|
||||||
|
let virtualStart: Int
|
||||||
|
let virtualMinutes: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only big breaks (Hofpause, Mittagspause) survive the mapper's small-break
|
||||||
|
/// collapse as a virtual gap between two periods.
|
||||||
|
func periodBreaks(_ periods: [WidgetPeriod]) -> [PeriodBreak] {
|
||||||
|
guard periods.count > 1 else { return [] }
|
||||||
|
return (0..<periods.count - 1).compactMap { i in
|
||||||
|
let gap = periods[i + 1].virtualStartMinutes - periods[i].virtualEndMinutes
|
||||||
|
return gap > 0
|
||||||
|
? PeriodBreak(virtualStart: periods[i].virtualEndMinutes, virtualMinutes: gap)
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Font and line-box sizes shared by every tile of one widget, derived from
|
||||||
|
/// the height of a single period.
|
||||||
|
struct TileMetrics {
|
||||||
|
let maxBodyLines: Int
|
||||||
|
let bodyFont: CGFloat
|
||||||
|
let titleBox: CGFloat
|
||||||
|
let bodyBox: CGFloat
|
||||||
|
|
||||||
|
init(periods: [WidgetPeriod], hourHeight: CGFloat) {
|
||||||
|
let slotMinutes = periods.first.map { $0.virtualEndMinutes - $0.virtualStartMinutes }
|
||||||
|
?? FALLBACK_SLOT_MINUTES
|
||||||
|
let slotHeight = CGFloat(slotMinutes) * hourHeight / 60
|
||||||
|
let inner = slotHeight - LESSON_GAP - 2 * TILE_PADDING_V
|
||||||
|
func font(lines: CGFloat) -> CGFloat {
|
||||||
|
inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines))
|
||||||
|
}
|
||||||
|
maxBodyLines = font(lines: 2) < STACKED_BODY_MIN_FONT ? 1 : 2
|
||||||
|
bodyFont = max(MIN_BODY_FONT, min(MAX_BODY_FONT, font(lines: CGFloat(maxBodyLines))))
|
||||||
|
titleBox = bodyFont * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT
|
||||||
|
bodyBox = bodyFont * LINE_BOX_PER_FONT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distributes the tile height like the in-app _TileContent: the title comes
|
||||||
|
/// first, then as many body lines as fit.
|
||||||
|
struct TileTextLayout {
|
||||||
|
let title: String
|
||||||
|
let titleBox: CGFloat
|
||||||
|
let titleFont: CGFloat
|
||||||
|
let titleLines: Int
|
||||||
|
let body: [String]
|
||||||
|
/// Set when there is no room for a line below the title: the room moves
|
||||||
|
/// next to it instead.
|
||||||
|
let inline: String?
|
||||||
|
/// Index of the line (0 = title row) that has to stay clear of the "+N"
|
||||||
|
/// badge, or nil when the text ends above it.
|
||||||
|
let badgeLine: Int?
|
||||||
|
|
||||||
|
init(lesson: WidgetLesson, innerHeight: CGFloat, innerWidth: CGFloat, metrics: TileMetrics) {
|
||||||
|
title = !lesson.subjectShort.isEmpty ? lesson.subjectShort : (lesson.subjectLong ?? "—")
|
||||||
|
let candidates = [lesson.room, lesson.teacher ?? lesson.originalTeacher]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
|
|
||||||
|
let singleTitleBox = min(metrics.titleBox, innerHeight)
|
||||||
|
let capacity = max(
|
||||||
|
0,
|
||||||
|
min(metrics.maxBodyLines, Int((innerHeight - singleTitleBox) / metrics.bodyBox + 0.05))
|
||||||
|
)
|
||||||
|
if capacity >= candidates.count {
|
||||||
|
body = candidates
|
||||||
|
} else if capacity == 1 {
|
||||||
|
// Rather share one line than drop the teacher entirely.
|
||||||
|
body = [candidates.joined(separator: " · ")]
|
||||||
|
} else {
|
||||||
|
body = []
|
||||||
|
}
|
||||||
|
if capacity == 0, let first = candidates.first, !title.contains(first) {
|
||||||
|
inline = first
|
||||||
|
} else {
|
||||||
|
inline = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let spare = innerHeight - singleTitleBox - CGFloat(body.count) * metrics.bodyBox
|
||||||
|
if spare >= singleTitleBox,
|
||||||
|
let wrappedLine = TileTextLayout.wrappedTitleLineBox(
|
||||||
|
title, innerWidth: innerWidth, metrics: metrics
|
||||||
|
) {
|
||||||
|
titleBox = 2 * wrappedLine
|
||||||
|
titleFont = wrappedLine / LINE_BOX_PER_FONT
|
||||||
|
titleLines = 2
|
||||||
|
} else {
|
||||||
|
titleBox = singleTitleBox
|
||||||
|
titleFont = singleTitleBox / LINE_BOX_PER_FONT
|
||||||
|
titleLines = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = titleBox + CGFloat(body.count) * metrics.bodyBox
|
||||||
|
let hasBadge = (lesson.siblingCount ?? 0) > 0
|
||||||
|
badgeLine = hasBadge && innerHeight - content < BADGE_HEIGHT ? body.count : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Line box for a title wrapped onto two lines, or nil when it should
|
||||||
|
/// stay on one: it fits there at a readable size, or it has no word
|
||||||
|
/// boundary to break at. The box is capped so the longest word still
|
||||||
|
/// fits the tile width.
|
||||||
|
private static func wrappedTitleLineBox(
|
||||||
|
_ title: String,
|
||||||
|
innerWidth: CGFloat,
|
||||||
|
metrics: TileMetrics
|
||||||
|
) -> CGFloat? {
|
||||||
|
func widthPerFontPoint(_ text: String) -> CGFloat {
|
||||||
|
let font = UIFont.systemFont(ofSize: 100, weight: .medium)
|
||||||
|
return (text as NSString).size(withAttributes: [.font: font]).width / 100
|
||||||
|
}
|
||||||
|
if widthPerFontPoint(title) * TITLE_WRAP_BELOW_FONT <= innerWidth { return nil }
|
||||||
|
let words = title
|
||||||
|
.replacingOccurrences(of: "-", with: "- ")
|
||||||
|
.split(separator: " ")
|
||||||
|
.map(String.init)
|
||||||
|
guard words.count > 1, let widest = words.map(widthPerFontPoint).max(), widest > 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return min(metrics.titleBox, innerWidth / widest * LINE_BOX_PER_FONT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
/// Parses the `#RRGGBB` tile colour resolved in Dart.
|
||||||
|
init?(widgetHex hex: String?) {
|
||||||
|
guard let hex, hex.count == 7, hex.hasPrefix("#"),
|
||||||
|
let value = UInt32(hex.dropFirst(), radix: 16) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.init(
|
||||||
|
red: Double((value >> 16) & 0xFF) / 255,
|
||||||
|
green: Double((value >> 8) & 0xFF) / 255,
|
||||||
|
blue: Double(value & 0xFF) / 255
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WidgetLesson {
|
||||||
|
var tileColor: Color {
|
||||||
|
if let resolved = Color(widgetHex: color) { return resolved }
|
||||||
|
// Only reached for payloads written before Dart shipped the resolved
|
||||||
|
// tile colour; mirrors lesson_color.dart.
|
||||||
|
switch status {
|
||||||
|
// No time-based highlight: the widget timeline only ticks every
|
||||||
|
// ~30min, so "current" and "past" would be stale most of the day.
|
||||||
|
case .regular, .past, .ongoing:
|
||||||
|
return Color(red: 153 / 255, green: 51 / 255, blue: 51 / 255)
|
||||||
|
case .cancelled: return .black
|
||||||
|
case .irregular: return Color(red: 143 / 255, green: 25 / 255, blue: 179 / 255)
|
||||||
|
case .teacherChanged: return Color(red: 41 / 255, green: 99 / 255, blue: 155 / 255)
|
||||||
|
case .duty: return Color(red: 0, green: 121 / 255, blue: 107 / 255)
|
||||||
|
case .event:
|
||||||
|
return customColor != nil
|
||||||
|
? Color(red: 1, green: 160 / 255, blue: 0)
|
||||||
|
: Color(red: 46 / 255, green: 125 / 255, blue: 50 / 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,36 +20,44 @@ struct TimetableWeekView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func content(data: WidgetTimetableData) -> some View {
|
private func content(data: WidgetTimetableData) -> some View {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let days = (0..<5).map { offset in
|
||||||
|
cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
|
||||||
|
}
|
||||||
|
let weekLessons = data.lessons.filter { lesson in
|
||||||
|
days.contains { cal.isDate(lesson.start, inSameDayAs: $0) }
|
||||||
|
}
|
||||||
|
let periods = trimTrailingPeriods(data.periods, lessons: weekLessons)
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
header(data: data)
|
header(data: data)
|
||||||
dayHeaderRow(data: data)
|
dayHeaderRow(days: days)
|
||||||
|
.padding(.top, GRID_MARGIN_TOP)
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
|
let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
|
||||||
let hourHeight = max(
|
|
||||||
MIN_HOUR_HEIGHT,
|
|
||||||
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
|
|
||||||
)
|
|
||||||
let dayColumnWidth = (geo.size.width - 28 - 4) / 5
|
|
||||||
// 45pt is the minimum where a 7pt-font room (e.g. "F2.04")
|
|
||||||
// plus the subject fits without `minimumScaleFactor`
|
|
||||||
// shrinking the subject into illegibility.
|
|
||||||
let subjectOnly = dayColumnWidth < 45
|
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
timeLabelsColumn(hourHeight: hourHeight, periods: data.periods)
|
PeriodRuler(periods: periods, hourHeight: height)
|
||||||
.frame(width: 28, alignment: .topTrailing)
|
|
||||||
columnDivider
|
columnDivider
|
||||||
ForEach(0..<5, id: \.self) { offset in
|
ForEach(days.indices, id: \.self) { index in
|
||||||
column(
|
TimeGridView(
|
||||||
data: data,
|
lessons: weekLessons.filter {
|
||||||
offset: offset,
|
cal.isDate($0.start, inSameDayAs: days[index])
|
||||||
hourHeight: hourHeight,
|
},
|
||||||
subjectOnly: subjectOnly
|
periods: periods,
|
||||||
|
hourHeight: height,
|
||||||
|
holiday: holiday(on: days[index], data: data),
|
||||||
|
isToday: cal.isDate(days[index], inSameDayAs: entry.date)
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity)
|
if index < 4 { columnDivider }
|
||||||
if offset < 4 { columnDivider }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func holiday(on day: Date, data: WidgetTimetableData) -> WidgetDayInfo? {
|
||||||
|
data.days?.first {
|
||||||
|
$0.isHoliday && Calendar.current.isDate($0.date, inSameDayAs: day)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,115 +71,55 @@ struct TimetableWeekView: View {
|
|||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
let week = cal.component(.weekOfYear, from: data.anchorDate)
|
let week = cal.component(.weekOfYear, from: data.anchorDate)
|
||||||
let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate
|
let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate
|
||||||
return HStack {
|
return HStack(spacing: 6) {
|
||||||
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
|
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.8)
|
.minimumScaleFactor(0.65)
|
||||||
Spacer()
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.fixedSize()
|
||||||
}
|
}
|
||||||
|
.frame(height: TITLE_ROW_HEIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dayHeaderRow(data: WidgetTimetableData) -> some View {
|
/// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
|
||||||
let cal = Calendar.current
|
/// today inside an accent circle.
|
||||||
// `.frame(maxWidth: .infinity)` is critical: without an explicit
|
private func dayHeaderRow(days: [Date]) -> some View {
|
||||||
// greedy width (or a true `Spacer()` inside), this HStack collapses
|
|
||||||
// to the sum of its fixed-width children in a `.leading` VStack
|
|
||||||
// and the 5 day-columns end up sharing ~5pt of width — the labels
|
|
||||||
// crunch to the left and stop aligning with the grid columns below.
|
|
||||||
// The fixed height keeps `columnDivider`'s vertically-flexible
|
// The fixed height keeps `columnDivider`'s vertically-flexible
|
||||||
// Rectangle from stealing space from the GeometryReader.
|
// Rectangle from stealing space from the GeometryReader below.
|
||||||
return HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Spacer().frame(width: 28)
|
Spacer().frame(width: RULER_WIDTH)
|
||||||
columnDivider
|
columnDivider
|
||||||
ForEach(0..<5, id: \.self) { offset in
|
ForEach(days.indices, id: \.self) { index in
|
||||||
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
|
dayHeaderCell(days[index])
|
||||||
VStack(spacing: -1) {
|
.frame(maxWidth: .infinity)
|
||||||
Text(weekday(for: day))
|
if index < 4 { columnDivider }
|
||||||
.font(.system(size: 10, weight: .bold))
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
Text(shortDate(day))
|
|
||||||
.font(.system(size: 8))
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
if offset < 4 { columnDivider }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 20pt fits 10pt weekday + 8pt date with tight -1pt spacing.
|
.frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT)
|
||||||
.frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View {
|
private func dayHeaderCell(_ day: Date) -> some View {
|
||||||
let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
|
// `entry.date`, not `Date()` — WidgetKit archives entries ahead of
|
||||||
let totalHeight = CGFloat(totalMin) * hourHeight / 60.0
|
// time, the midnight entry has to mark its own day.
|
||||||
// Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked
|
let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date)
|
||||||
// time+name no longer fits in one period slot and the two labels
|
return VStack(spacing: 1) {
|
||||||
// visually clip into the next slot. Drop to a single bold period
|
Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased())
|
||||||
// number until there's room to read both lines.
|
.font(.system(size: 9, weight: .medium))
|
||||||
let compact = hourHeight < 26
|
.foregroundStyle(isToday ? palette.accent : palette.textSecondary)
|
||||||
return ZStack(alignment: .topTrailing) {
|
.lineLimit(1)
|
||||||
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
Text("\(Calendar.current.component(.day, from: day))")
|
||||||
Rectangle()
|
.font(.system(size: 11, weight: isToday ? .bold : .regular))
|
||||||
.fill(palette.divider)
|
.foregroundStyle(isToday ? palette.onAccent : palette.textPrimary)
|
||||||
.frame(height: 1)
|
.lineLimit(1)
|
||||||
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
|
.frame(width: 18, height: 18)
|
||||||
}
|
.background(Circle().fill(isToday ? palette.accent : Color.clear))
|
||||||
ForEach(periods, id: \.startMinutes) { period in
|
|
||||||
VStack(alignment: .trailing, spacing: -2) {
|
|
||||||
if compact {
|
|
||||||
Text("\(period.name).")
|
|
||||||
.font(.system(size: 9, weight: .bold))
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
.lineLimit(1)
|
|
||||||
} else {
|
|
||||||
Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60))
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
.lineLimit(1)
|
|
||||||
Text("\(period.name).")
|
|
||||||
.font(.system(size: 7, weight: .bold))
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.trailing, 4)
|
|
||||||
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(height: totalHeight, alignment: .topTrailing)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func column(
|
|
||||||
data: WidgetTimetableData,
|
|
||||||
offset: Int,
|
|
||||||
hourHeight: CGFloat,
|
|
||||||
subjectOnly: Bool
|
|
||||||
) -> some View {
|
|
||||||
let cal = Calendar.current
|
|
||||||
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
|
|
||||||
let lessonsForDay = data.lessons.filter { cal.isDate($0.start, inSameDayAs: day) }
|
|
||||||
return TimeGridView(
|
|
||||||
lessons: lessonsForDay,
|
|
||||||
periods: data.periods,
|
|
||||||
hourHeight: hourHeight,
|
|
||||||
showRoom: !subjectOnly,
|
|
||||||
showTeacher: !subjectOnly,
|
|
||||||
showTimeLabels: false,
|
|
||||||
horizontalPadding: 3
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func weekday(for date: Date) -> String {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.locale = Locale(identifier: "de_DE")
|
|
||||||
f.dateFormat = "EE"
|
|
||||||
return f.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func placeholder(_ message: String) -> some View {
|
private func placeholder(_ message: String) -> some View {
|
||||||
|
|||||||
@@ -41,11 +41,13 @@ struct TimetableDayProvider: TimelineProvider {
|
|||||||
in context: Context,
|
in context: Context,
|
||||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
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
|
// 30 min mirrors the Dart workmanager cadence. iOS treats this as
|
||||||
// advisory; the "Stand:" label tells the user when data is stale.
|
// advisory; the boundary entries below keep the rendered day correct
|
||||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
// even when no reload is granted, and the "Stand:" label tells the
|
||||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
// 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,
|
in context: Context,
|
||||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
||||||
) {
|
) {
|
||||||
let entry = TimetableEntry.current(variant: .week)
|
let now = Date()
|
||||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +122,58 @@ struct TimetableEntry: TimelineEntry {
|
|||||||
themeMode: WidgetDataLoader.themeMode()
|
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 {
|
extension View {
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ enum WidgetLessonStatus: String, Codable {
|
|||||||
case irregular
|
case irregular
|
||||||
case teacherChanged
|
case teacherChanged
|
||||||
case event
|
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 {
|
struct WidgetLesson: Codable {
|
||||||
@@ -18,10 +27,15 @@ struct WidgetLesson: Codable {
|
|||||||
let subjectShort: String
|
let subjectShort: String
|
||||||
let subjectLong: String?
|
let subjectLong: String?
|
||||||
let room: 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 teacher: String?
|
||||||
let originalTeacher: String?
|
let originalTeacher: String?
|
||||||
let status: WidgetLessonStatus
|
let status: WidgetLessonStatus
|
||||||
let customColor: String?
|
let customColor: String?
|
||||||
|
// Tile colour resolved in Dart as "#RRGGBB" (subject colours, palettes);
|
||||||
|
// nil only for payloads written before the field existed.
|
||||||
|
let color: String?
|
||||||
let siblingCount: Int?
|
let siblingCount: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +47,12 @@ struct WidgetPeriod: Codable {
|
|||||||
let virtualEndMinutes: Int
|
let virtualEndMinutes: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct WidgetDayInfo: Codable {
|
||||||
|
let date: Date
|
||||||
|
let isHoliday: Bool
|
||||||
|
let holidayName: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct WidgetTimetableData: Codable {
|
struct WidgetTimetableData: Codable {
|
||||||
let fetchedAt: Date
|
let fetchedAt: Date
|
||||||
let anchorDate: Date
|
let anchorDate: Date
|
||||||
@@ -40,12 +60,17 @@ struct WidgetTimetableData: Codable {
|
|||||||
let periods: [WidgetPeriod]
|
let periods: [WidgetPeriod]
|
||||||
let isHoliday: Bool
|
let isHoliday: Bool
|
||||||
let holidayName: String?
|
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 {
|
enum WidgetDataKey {
|
||||||
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
|
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
|
||||||
static let dayData = "widget_data_day_v1"
|
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 loggedIn = "widget_data_logged_in_v1"
|
||||||
static let themeMode = "widget_setting_theme_mode_v1"
|
static let themeMode = "widget_setting_theme_mode_v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,27 @@ struct WidgetPalette {
|
|||||||
let textPrimary: Color
|
let textPrimary: Color
|
||||||
let textSecondary: Color
|
let textSecondary: Color
|
||||||
let divider: Color
|
let divider: Color
|
||||||
|
let accent: Color
|
||||||
|
let onAccent: Color
|
||||||
let breakBlock: Color
|
let breakBlock: Color
|
||||||
|
let todayTint: Color
|
||||||
|
let holidayTint: Color
|
||||||
let watermarkOpacity: Double
|
let watermarkOpacity: Double
|
||||||
|
|
||||||
|
// ColorScheme.fromSeed(#993333).primary of the app's light / dark theme.
|
||||||
|
private static let lightAccent = Color(red: 0x90 / 255, green: 0x4A / 255, blue: 0x47 / 255)
|
||||||
|
private static let darkAccent = Color(red: 0xFF / 255, green: 0xB3 / 255, blue: 0xAF / 255)
|
||||||
|
|
||||||
static let light = WidgetPalette(
|
static let light = WidgetPalette(
|
||||||
background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255),
|
background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255),
|
||||||
textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255),
|
textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255),
|
||||||
textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255),
|
textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255),
|
||||||
divider: Color.black.opacity(0x22 / 255.0),
|
divider: Color.black.opacity(0x22 / 255.0),
|
||||||
breakBlock: Color.black.opacity(0x0C / 255.0),
|
accent: lightAccent,
|
||||||
|
onAccent: .white,
|
||||||
|
breakBlock: lightAccent.opacity(0x32 / 255.0),
|
||||||
|
todayTint: lightAccent.opacity(0x0E / 255.0),
|
||||||
|
holidayTint: Color.black.opacity(0x32 / 255.0),
|
||||||
watermarkOpacity: 0.014
|
watermarkOpacity: 0.014
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,7 +38,11 @@ struct WidgetPalette {
|
|||||||
textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255),
|
textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255),
|
||||||
textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255),
|
textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255),
|
||||||
divider: Color.white.opacity(0x33 / 255.0),
|
divider: Color.white.opacity(0x33 / 255.0),
|
||||||
breakBlock: Color.white.opacity(0x14 / 255.0),
|
accent: darkAccent,
|
||||||
|
onAccent: Color(red: 0x57 / 255, green: 0x1D / 255, blue: 0x1D / 255),
|
||||||
|
breakBlock: darkAccent.opacity(0x32 / 255.0),
|
||||||
|
todayTint: darkAccent.opacity(0x0E / 255.0),
|
||||||
|
holidayTint: Color.white.opacity(0x32 / 255.0),
|
||||||
watermarkOpacity: 0.025
|
watermarkOpacity: 0.025
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
app_identifier("eu.mhsl.marianum.mobile.client")
|
||||||
|
team_id("MY55VF3KPG")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
# App-Store-Auslieferung über fastlane deliver. Die Screenshots erzeugt
|
||||||
|
# ../tool/screenshots_ios.sh (Demo-Login + integration_test) direkt in
|
||||||
|
# screenshots/de-DE/; deliver ordnet sie anhand der Pixelgröße iPhone 6,5″ bzw.
|
||||||
|
# iPad 13″ zu.
|
||||||
|
#
|
||||||
|
# Authentifizierung über einen App-Store-Connect-API-Key (JSON im fastlane-
|
||||||
|
# Format, siehe README.md). Pfad per APP_STORE_CONNECT_API_KEY setzen; die Datei
|
||||||
|
# selbst wird per .gitignore nicht eingecheckt.
|
||||||
|
platform :ios do
|
||||||
|
desc "Nur Screenshots hochladen (ersetzt die vorhandenen, verändert keine Texte/Binaries)"
|
||||||
|
lane :upload_screenshots do
|
||||||
|
upload_to_app_store(
|
||||||
|
api_key_path: ENV["APP_STORE_CONNECT_API_KEY"] || "fastlane/app-store-connect-api-key.json",
|
||||||
|
skip_binary_upload: true,
|
||||||
|
skip_metadata: true,
|
||||||
|
skip_app_version_update: true,
|
||||||
|
skip_screenshots: false,
|
||||||
|
overwrite_screenshots: true,
|
||||||
|
screenshots_path: "./fastlane/screenshots",
|
||||||
|
precheck_include_in_app_purchases: false,
|
||||||
|
run_precheck_before_submit: false,
|
||||||
|
force: true, # kein HTML-Vorschau-Bestätigungsschritt
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Fastlane – App-Store-Screenshots & Upload
|
||||||
|
|
||||||
|
iOS-Gegenstück zu `android/fastlane`. Die App muss dafür **nicht** neu gebaut
|
||||||
|
werden – der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte aus
|
||||||
|
Fixtures.
|
||||||
|
|
||||||
|
## 1. Screenshots aufnehmen
|
||||||
|
|
||||||
|
Aus dem **Client**-Wurzelverzeichnis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tool/screenshots_ios.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Fährt „iPhone 14 Plus“ (6,5″, 1284×2778) und „iPad Pro 13-inch (M5)“
|
||||||
|
(13″, 2064×2752) nacheinander durch und legt die PNGs in
|
||||||
|
`materials/screenshots/{ios/6-5-inch,ipados/13-inch}/` sowie als JPEG-Upload-Kopie
|
||||||
|
(App Store Connect lehnt Alpha-Kanäle ab) in `screenshots/de-DE/` ab. Fehlende
|
||||||
|
Simulatoren legt das Skript selbst an.
|
||||||
|
|
||||||
|
- Nur eine Größe: `PROFILES="iphone" tool/screenshots_ios.sh`
|
||||||
|
- Andere Simulatoren: `IPHONE_SIM="iPhone 11 Pro Max" IPAD_SIM="…" tool/screenshots_ios.sh`
|
||||||
|
|
||||||
|
App und Schlüsselbund der verwendeten Simulatoren werden dabei zurückgesetzt.
|
||||||
|
|
||||||
|
## 2. Hochladen
|
||||||
|
|
||||||
|
fastlane installieren (`brew install fastlane`), in App Store Connect unter
|
||||||
|
*Benutzer und Zugriff → Integrationen → App Store Connect API* einen Key mit
|
||||||
|
Rolle *App-Manager* anlegen und als JSON ablegen:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key_id": "ABC123XYZ",
|
||||||
|
"issuer_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"key": "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----",
|
||||||
|
"in_house": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export APP_STORE_CONNECT_API_KEY=/pfad/zu/app-store-connect-api-key.json
|
||||||
|
cd ios
|
||||||
|
fastlane upload_screenshots
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Screenshots landen in der aktuell bearbeitbaren App-Store-Version
|
||||||
|
(„Vorbereitung für Einreichung“); vorhandene Screenshots werden ersetzt. Der
|
||||||
|
API-Key wird **nicht** eingecheckt (siehe `.gitignore`).
|
||||||
@@ -1 +0,0 @@
|
|||||||
class ApiRequest {}
|
|
||||||
@@ -7,4 +7,9 @@ abstract class ApiResponse {
|
|||||||
|
|
||||||
@JsonKey(includeIfNull: false)
|
@JsonKey(includeIfNull: false)
|
||||||
Map<String, String>? headers;
|
Map<String, String>? headers;
|
||||||
|
|
||||||
|
/// Entries the decoder skipped (see `decodeListLenient`), reported by the
|
||||||
|
/// API layer once the response is back on the main isolate.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
List<String> parseWarnings = const [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,19 @@ import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
|
|||||||
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
|
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
|
||||||
|
|
||||||
/// Demo fixtures for the mobile capability flags — everything granted so the
|
/// Demo fixtures for the mobile capability flags — everything granted so the
|
||||||
/// demo persona sees every feature (incl. push) as available.
|
/// demo persona sees every feature (incl. push) as available and the timetable
|
||||||
|
/// scroll range stays unlimited (null day counts).
|
||||||
class DemoCapabilities {
|
class DemoCapabilities {
|
||||||
const DemoCapabilities._();
|
const DemoCapabilities._();
|
||||||
|
|
||||||
static CapabilitiesState state() => const CapabilitiesState(
|
static CapabilitiesState state() => const CapabilitiesState(
|
||||||
viewForeignTimetables: true,
|
viewForeignTimetables: true,
|
||||||
pushNotifications: 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,
|
loaded: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../../../model/account_data.dart';
|
import '../../../session/session_manager.dart';
|
||||||
import '../../marianumcloud/talk/chat/get_chat_response.dart';
|
import '../../marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
import '../../marianumcloud/talk/room/get_room_response.dart';
|
import '../../marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../demo_persona.dart';
|
import '../demo_persona.dart';
|
||||||
@@ -204,7 +204,8 @@ class DemoTalk {
|
|||||||
id: base + 2,
|
id: base + 2,
|
||||||
token: token,
|
token: token,
|
||||||
ago: const Duration(days: 1, hours: 6),
|
ago: const Duration(days: 1, hours: 6),
|
||||||
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
|
message:
|
||||||
|
'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
|
||||||
),
|
),
|
||||||
_msg(
|
_msg(
|
||||||
id: base + 3,
|
id: base + 3,
|
||||||
@@ -324,6 +325,22 @@ class DemoTalk {
|
|||||||
message: 'Gute Idee, ich sammle schon mal Vorschläge.',
|
message: 'Gute Idee, ich sammle schon mal Vorschläge.',
|
||||||
reactions: {'👍': 3},
|
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 Duration ago,
|
||||||
required String message,
|
required String message,
|
||||||
Map<String, int>? reactions,
|
Map<String, int>? reactions,
|
||||||
|
bool markdown = false,
|
||||||
}) => _msg(
|
}) => _msg(
|
||||||
id: id,
|
id: id,
|
||||||
token: token,
|
token: token,
|
||||||
actor: AccountData().getUsername(),
|
actor: SessionManager().requireNextcloud().username,
|
||||||
display: DemoPersona.studentName,
|
display: DemoPersona.studentName,
|
||||||
ago: ago,
|
ago: ago,
|
||||||
message: message,
|
message: message,
|
||||||
reactions: reactions,
|
reactions: reactions,
|
||||||
|
markdown: markdown,
|
||||||
);
|
);
|
||||||
|
|
||||||
static GetChatResponseObject _msg({
|
static GetChatResponseObject _msg({
|
||||||
@@ -364,6 +383,7 @@ class DemoTalk {
|
|||||||
required Duration ago,
|
required Duration ago,
|
||||||
required String message,
|
required String message,
|
||||||
Map<String, int>? reactions,
|
Map<String, int>? reactions,
|
||||||
|
bool markdown = false,
|
||||||
}) => GetChatResponseObject(
|
}) => GetChatResponseObject(
|
||||||
id,
|
id,
|
||||||
token,
|
token,
|
||||||
@@ -380,6 +400,7 @@ class DemoTalk {
|
|||||||
reactions,
|
reactions,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
markdown,
|
||||||
);
|
);
|
||||||
|
|
||||||
static GetRoomResponseObject _room({
|
static GetRoomResponseObject _room({
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/io_client.dart';
|
||||||
|
|
||||||
|
import '../../model/endpoint_data.dart';
|
||||||
|
import '../errors/network_exception.dart';
|
||||||
|
import 'demo_mode.dart';
|
||||||
|
|
||||||
|
/// Demo sessions carry made-up credentials. Every request they sent to the
|
||||||
|
/// real Nextcloud would count as a failed login there and, in bulk, trip the
|
||||||
|
/// server's brute-force throttle for the whole IP (HTTP 429 for everyone
|
||||||
|
/// behind it). Installed app-wide via [http.runWithClient].
|
||||||
|
class DemoHttpClient extends http.BaseClient {
|
||||||
|
final http.Client _inner = IOClient();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||||
|
if (DemoMode.active && isNextcloudHost(request.url)) {
|
||||||
|
return Future.error(NetworkException.demoBlocked(request.url));
|
||||||
|
}
|
||||||
|
return _inner.send(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() => _inner.close();
|
||||||
|
|
||||||
|
static bool isNextcloudHost(Uri url) =>
|
||||||
|
url.host == EndpointData().nextcloud().domain;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'data/demo_absence.dart';
|
||||||
import 'data/demo_breaker.dart';
|
import 'data/demo_breaker.dart';
|
||||||
import 'data/demo_holidays.dart';
|
import 'data/demo_holidays.dart';
|
||||||
import 'data/demo_timetable.dart';
|
import 'data/demo_timetable.dart';
|
||||||
@@ -31,6 +32,8 @@ class DemoMarianumConnect {
|
|||||||
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
|
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
|
||||||
case 'timetable/holidays':
|
case 'timetable/holidays':
|
||||||
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
|
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
|
||||||
|
case 'timetable/custom-events':
|
||||||
|
return DemoTimetable.customEvents().toJson();
|
||||||
case 'holidays':
|
case 'holidays':
|
||||||
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
||||||
case 'users/search':
|
case 'users/search':
|
||||||
@@ -39,6 +42,10 @@ class DemoMarianumConnect {
|
|||||||
.toList();
|
.toList();
|
||||||
case 'breaker':
|
case 'breaker':
|
||||||
return DemoBreaker.none().toJson();
|
return DemoBreaker.none().toJson();
|
||||||
|
case 'absence/classes':
|
||||||
|
return DemoAbsence.classes();
|
||||||
|
case 'absence/prefill':
|
||||||
|
return DemoAbsence.prefill().toJson();
|
||||||
case 'timetable/elements/teachers':
|
case 'timetable/elements/teachers':
|
||||||
case 'timetable/elements/students':
|
case 'timetable/elements/students':
|
||||||
case 'timetable/elements/classes':
|
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.
|
/// Central switch for the client-side demo mode.
|
||||||
///
|
///
|
||||||
@@ -8,7 +8,7 @@ import '../../model/account_data.dart';
|
|||||||
/// Play reviewers and automated screenshot runs see a fully populated app
|
/// Play reviewers and automated screenshot runs see a fully populated app
|
||||||
/// without a real account and without any network dependency.
|
/// without a real account and without any network dependency.
|
||||||
///
|
///
|
||||||
/// The flag is persisted through [AccountData.isDemo], so a demo session
|
/// The flag is persisted through [Session.isDemo], so a demo session
|
||||||
/// survives cold starts exactly like a normal login. It works in release builds
|
/// survives cold starts exactly like a normal login. It works in release builds
|
||||||
/// too — reviewers run the shipped release build.
|
/// too — reviewers run the shipped release build.
|
||||||
class DemoMode {
|
class DemoMode {
|
||||||
@@ -23,5 +23,5 @@ class DemoMode {
|
|||||||
username.trim().toLowerCase().startsWith(usernamePrefix);
|
username.trim().toLowerCase().startsWith(usernamePrefix);
|
||||||
|
|
||||||
/// True while the active session is a demo session.
|
/// True while the active session is a demo session.
|
||||||
static bool get active => AccountData().isDemo;
|
static bool get active => SessionManager().isDemo;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +5,12 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
|
import '../../session/session.dart';
|
||||||
import '../api_error.dart';
|
import '../api_error.dart';
|
||||||
|
import '../http_errors.dart';
|
||||||
import '../marianumcloud/talk/talk_error.dart';
|
import '../marianumcloud/talk/talk_error.dart';
|
||||||
import 'app_exception.dart';
|
import 'app_exception.dart';
|
||||||
import 'auth_exception.dart';
|
|
||||||
import 'network_exception.dart';
|
import 'network_exception.dart';
|
||||||
import 'not_found_exception.dart';
|
|
||||||
import 'parse_exception.dart';
|
import 'parse_exception.dart';
|
||||||
import 'server_exception.dart';
|
import 'server_exception.dart';
|
||||||
import 'talk_exception.dart';
|
import 'talk_exception.dart';
|
||||||
@@ -59,26 +59,19 @@ AppException? _dioToAppException(DioException error) {
|
|||||||
/// status plus a trimmed body preview (same format as the Talk API errors).
|
/// status plus a trimmed body preview (same format as the Talk API errors).
|
||||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||||
final status = error.statusCode;
|
final status = error.statusCode;
|
||||||
final body = error.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
final preview = previewBody(error.body);
|
||||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
final detail = preview.isEmpty
|
||||||
final detail = body.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
? 'HTTP $status'
|
||||||
switch (status) {
|
: 'HTTP $status body=$preview';
|
||||||
case 401:
|
if (status == 429) {
|
||||||
return AuthException.unauthorized(technicalDetails: detail);
|
return ServerException(
|
||||||
case 403:
|
statusCode: status,
|
||||||
return AuthException.forbidden(technicalDetails: detail);
|
userMessage:
|
||||||
case 404:
|
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
|
||||||
return NotFoundException(technicalDetails: detail);
|
technicalDetails: detail,
|
||||||
case 429:
|
);
|
||||||
return ServerException(
|
|
||||||
statusCode: status,
|
|
||||||
userMessage:
|
|
||||||
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
|
|
||||||
technicalDetails: detail,
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return ServerException(statusCode: status, technicalDetails: detail);
|
|
||||||
}
|
}
|
||||||
|
return exceptionForStatus(status, detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
||||||
@@ -86,6 +79,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
|||||||
if (error is AppException) return error.userMessage;
|
if (error is AppException) return error.userMessage;
|
||||||
|
|
||||||
if (error is TalkError) return TalkException(error).userMessage;
|
if (error is TalkError) return TalkException(error).userMessage;
|
||||||
|
if (error is NextcloudUnavailableException) {
|
||||||
|
return 'Diese Funktion ist mit deinem Konto nicht verfügbar.';
|
||||||
|
}
|
||||||
|
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
final mapped = _dioToAppException(error);
|
final mapped = _dioToAppException(error);
|
||||||
@@ -136,6 +132,7 @@ String? errorToTechnicalDetails(Object? error) {
|
|||||||
bool errorAllowsRetry(Object? error) {
|
bool errorAllowsRetry(Object? error) {
|
||||||
if (error == null) return true;
|
if (error == null) return true;
|
||||||
if (error is AppException) return error.allowRetry;
|
if (error is AppException) return error.allowRetry;
|
||||||
|
if (error is NextcloudUnavailableException) return false;
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
final mapped = _dioToAppException(error);
|
final mapped = _dioToAppException(error);
|
||||||
if (mapped != null) return mapped.allowRetry;
|
if (mapped != null) return mapped.allowRetry;
|
||||||
|
|||||||
@@ -13,4 +13,10 @@ class NetworkException extends AppException {
|
|||||||
'Der Server hat zu lange gebraucht. Bitte versuche es erneut.',
|
'Der Server hat zu lange gebraucht. Bitte versuche es erneut.',
|
||||||
technicalDetails: technicalDetails,
|
technicalDetails: technicalDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// A request a demo session must never send (see `DemoHttpClient`).
|
||||||
|
factory NetworkException.demoBlocked(Uri url) => NetworkException(
|
||||||
|
userMessage: 'Im Demo-Modus nicht verfügbar.',
|
||||||
|
technicalDetails: 'demo mode blocked $url',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
class TalkException extends AppException {
|
||||||
final TalkError source;
|
final TalkError source;
|
||||||
|
|
||||||
TalkException(this.source)
|
TalkException(this.source, {String? technicalDetails})
|
||||||
: super(
|
: super(
|
||||||
userMessage: _mapMessage(source),
|
userMessage: _mapMessage(source),
|
||||||
technicalDetails:
|
technicalDetails:
|
||||||
|
technicalDetails ??
|
||||||
'Talk ${source.status} (${source.code}): ${source.message}',
|
'Talk ${source.status} (${source.code}): ${source.message}',
|
||||||
allowRetry: source.code >= 500,
|
allowRetry: source.code >= 500,
|
||||||
);
|
);
|
||||||
|
|
||||||
static String _mapMessage(TalkError e) {
|
static String _mapMessage(TalkError e) {
|
||||||
switch (e.code) {
|
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:
|
case 401:
|
||||||
return 'Bitte melde dich erneut an, um auf Talk zuzugreifen.';
|
return 'Bitte melde dich erneut an, um auf Talk zuzugreifen.';
|
||||||
case 403:
|
case 403:
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'errors/app_exception.dart';
|
||||||
|
import 'errors/auth_exception.dart';
|
||||||
|
import 'errors/network_exception.dart';
|
||||||
|
import 'errors/not_found_exception.dart';
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [AppException] matching a non-2xx HTTP [status], carrying [detail] as
|
||||||
|
/// technical details: 401/403 map to auth errors, 404 to not-found, everything
|
||||||
|
/// else to a generic server error.
|
||||||
|
AppException exceptionForStatus(int status, String detail) => switch (status) {
|
||||||
|
401 => AuthException.unauthorized(technicalDetails: detail),
|
||||||
|
403 => AuthException.forbidden(technicalDetails: detail),
|
||||||
|
404 => NotFoundException(technicalDetails: detail),
|
||||||
|
_ => ServerException(statusCode: status, technicalDetails: detail),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Throws [exceptionForStatus].
|
||||||
|
Never throwForStatus(int status, String detail) =>
|
||||||
|
throw exceptionForStatus(status, detail);
|
||||||
|
|
||||||
|
/// Throws [exceptionForStatus] unless [response] is 2xx.
|
||||||
|
void ensureSuccess(String label, http.Response response) {
|
||||||
|
final status = response.statusCode;
|
||||||
|
if (status >= 200 && status < 300) return;
|
||||||
|
throwForStatus(status, httpErrorDetail(label, response.body, status));
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
/// Decodes server lists element by element so one entry the model cannot map
|
||||||
|
/// (a field the server dropped, an enum value it added) is skipped and
|
||||||
|
/// recorded instead of failing the whole response and with it the screen.
|
||||||
|
///
|
||||||
|
/// Skipped entries are collected per isolate; the API layer drains them with
|
||||||
|
/// [drainDecodeWarnings] and reports them, so a drift in the server's format
|
||||||
|
/// still reaches us without taking the feature down for the user.
|
||||||
|
final List<String> _warnings = [];
|
||||||
|
|
||||||
|
List<T> decodeListLenient<T>(
|
||||||
|
Object? raw,
|
||||||
|
T Function(Map<String, dynamic>) fromJson, {
|
||||||
|
required String what,
|
||||||
|
}) {
|
||||||
|
final items = <T>[];
|
||||||
|
if (raw is! List<dynamic>) {
|
||||||
|
throw FormatException(
|
||||||
|
'expected a list of ${what}s, got ${raw.runtimeType}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final entry in raw) {
|
||||||
|
try {
|
||||||
|
items.add(fromJson(entry as Map<String, dynamic>));
|
||||||
|
} on CheckedFromJsonException catch (e) {
|
||||||
|
_warnings.add('$what: $e');
|
||||||
|
} on TypeError catch (e) {
|
||||||
|
_warnings.add('$what: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<T> decodeSetLenient<T>(
|
||||||
|
Object? raw,
|
||||||
|
T Function(Map<String, dynamic>) fromJson, {
|
||||||
|
required String what,
|
||||||
|
}) => decodeListLenient(raw, fromJson, what: what).toSet();
|
||||||
|
|
||||||
|
/// Returns and clears the entries skipped since the last call.
|
||||||
|
List<String> drainDecodeWarnings() {
|
||||||
|
final out = List<String>.of(_warnings);
|
||||||
|
_warnings.clear();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -16,10 +16,9 @@ class DeleteAppPassword {
|
|||||||
Future<void> run({String? authorizationHeader}) async {
|
Future<void> run({String? authorizationHeader}) async {
|
||||||
await _client.delete(
|
await _client.delete(
|
||||||
NextcloudOcs.uri('core/apppassword'),
|
NextcloudOcs.uri('core/apppassword'),
|
||||||
headers: {
|
// An explicit header may belong to an inactive account, so the active
|
||||||
...NextcloudOcs.headers(),
|
// session's headers must not be required then.
|
||||||
'Authorization': ?authorizationHeader,
|
headers: NextcloudOcs.headers(authorization: authorizationHeader),
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../session/session_manager.dart';
|
||||||
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
|
|
||||||
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
||||||
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
|
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
|
||||||
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]),
|
/// authenticate with the app password (see [NextcloudCredentials.basicAuthHeader]),
|
||||||
/// which is what the push-v2 registration binds to.
|
/// which is what the push-v2 registration binds to.
|
||||||
///
|
///
|
||||||
/// Must authenticate with the *real* password — an app password cannot mint
|
/// Must authenticate with the *real* password — an app password cannot mint
|
||||||
@@ -18,22 +19,26 @@ class GetAppPassword {
|
|||||||
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
|
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
|
||||||
|
|
||||||
/// Returns the freshly minted app password. Throws on any transport or
|
/// Returns the freshly minted app password. Throws on any transport or
|
||||||
/// protocol error — callers treat push registration as best-effort and swallow
|
/// protocol error — a 401 becomes an [AuthException], which the login flow
|
||||||
/// failures.
|
/// reads as "Nextcloud rejects the password" (two-factor authentication or
|
||||||
|
/// password mismatch) and answers with the interactive Login Flow v2.
|
||||||
Future<String> run() async {
|
Future<String> run() async {
|
||||||
final response = await _client.get(
|
const label = 'Nextcloud getapppassword';
|
||||||
NextcloudOcs.uri('core/getapppassword'),
|
final response = (await sendGuarded(
|
||||||
headers: {
|
label,
|
||||||
...NextcloudOcs.headers(),
|
() => _client.get(
|
||||||
|
NextcloudOcs.uri('core/getapppassword'),
|
||||||
// Deliberately NOT the shared Authorization value: that one prefers
|
// Deliberately NOT the shared Authorization value: that one prefers
|
||||||
// the app password, but an app password cannot mint another one —
|
// the app password, but an app password cannot mint another one —
|
||||||
// this endpoint requires the real password.
|
// this endpoint requires the real password.
|
||||||
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
|
headers: NextcloudOcs.headers(
|
||||||
},
|
authorization: SessionManager()
|
||||||
);
|
.requireNextcloud()
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
.realPasswordBasicAuthHeader,
|
||||||
throw Exception('getapppassword HTTP ${response.statusCode}');
|
),
|
||||||
}
|
),
|
||||||
|
))!;
|
||||||
|
ensureSuccess(label, response);
|
||||||
final json = jsonDecode(utf8.decode(response.bodyBytes));
|
final json = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
final data = (json as Map)['ocs']?['data'];
|
final data = (json as Map)['ocs']?['data'];
|
||||||
final appPassword = data is Map ? data['apppassword'] as String? : null;
|
final appPassword = data is Map ? data['apppassword'] as String? : null;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
import '../../errors/server_exception.dart';
|
import '../../errors/server_exception.dart';
|
||||||
|
import '../../http_errors.dart';
|
||||||
|
import '../../json_lenient.dart';
|
||||||
|
import '../../marianumconnect/queries/report_client_error/client_error_reporter.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
import 'autocomplete_response.dart';
|
import 'autocomplete_response.dart';
|
||||||
|
|
||||||
@@ -31,16 +33,25 @@ class AutocompleteApi {
|
|||||||
'limit': '10',
|
'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) {
|
if (response.statusCode != HttpStatus.ok) {
|
||||||
throw ServerException(
|
throw ServerException(
|
||||||
statusCode: response.statusCode,
|
statusCode: response.statusCode,
|
||||||
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
final result = AutocompleteResponse.fromJson(
|
||||||
return AutocompleteResponse.fromJson(
|
NextcloudOcs.decode(response.body),
|
||||||
decoded['ocs'] as Map<String, dynamic>,
|
|
||||||
);
|
);
|
||||||
|
ClientErrorReporter.reportDecodeWarnings(
|
||||||
|
'Autocomplete',
|
||||||
|
drainDecodeWarnings(),
|
||||||
|
);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import '../../json_lenient.dart';
|
||||||
import '../files_sharing/queries/share/share.dart';
|
import '../files_sharing/queries/share/share.dart';
|
||||||
|
|
||||||
part 'autocomplete_response.g.dart';
|
part 'autocomplete_response.g.dart';
|
||||||
@@ -14,6 +15,7 @@ int shareTypeFromSource(String? source) {
|
|||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class AutocompleteResponse {
|
class AutocompleteResponse {
|
||||||
|
@JsonKey(fromJson: _entriesFromJson)
|
||||||
List<AutocompleteResponseObject> data;
|
List<AutocompleteResponseObject> data;
|
||||||
|
|
||||||
AutocompleteResponse(this.data);
|
AutocompleteResponse(this.data);
|
||||||
@@ -23,15 +25,31 @@ class AutocompleteResponse {
|
|||||||
Map<String, dynamic> toJson() => _$AutocompleteResponseToJson(this);
|
Map<String, dynamic> toJson() => _$AutocompleteResponseToJson(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<AutocompleteResponseObject> _entriesFromJson(Object? raw) =>
|
||||||
|
decodeListLenient(
|
||||||
|
raw,
|
||||||
|
AutocompleteResponseObject.fromJson,
|
||||||
|
what: 'autocomplete entry',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The server sends a user-status object for users that have one and an empty
|
||||||
|
/// string otherwise; only the status string itself is kept.
|
||||||
|
String? _statusFromJson(Object? raw) => switch (raw) {
|
||||||
|
Map<String, dynamic> status => status['status']?.toString(),
|
||||||
|
String status when status.isNotEmpty => status,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class AutocompleteResponseObject {
|
class AutocompleteResponseObject {
|
||||||
String id;
|
String id;
|
||||||
String label;
|
String label;
|
||||||
String? icon;
|
String? icon;
|
||||||
String? source;
|
String? source;
|
||||||
|
@JsonKey(fromJson: _statusFromJson)
|
||||||
String? status;
|
String? status;
|
||||||
String? subline;
|
String? subline;
|
||||||
String? shareWithDisplayNameUniqe;
|
String? shareWithDisplayNameUnique;
|
||||||
|
|
||||||
AutocompleteResponseObject(
|
AutocompleteResponseObject(
|
||||||
this.id,
|
this.id,
|
||||||
@@ -40,7 +58,7 @@ class AutocompleteResponseObject {
|
|||||||
this.source,
|
this.source,
|
||||||
this.status,
|
this.status,
|
||||||
this.subline,
|
this.subline,
|
||||||
this.shareWithDisplayNameUniqe,
|
this.shareWithDisplayNameUnique,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory AutocompleteResponseObject.fromJson(Map<String, dynamic> json) =>
|
factory AutocompleteResponseObject.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ part of 'autocomplete_response.dart';
|
|||||||
|
|
||||||
AutocompleteResponse _$AutocompleteResponseFromJson(
|
AutocompleteResponse _$AutocompleteResponseFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => AutocompleteResponse(
|
) => $checkedCreate('AutocompleteResponse', json, ($checkedConvert) {
|
||||||
(json['data'] as List<dynamic>)
|
final val = AutocompleteResponse(
|
||||||
.map(
|
$checkedConvert('data', (v) => _entriesFromJson(v)),
|
||||||
(e) => AutocompleteResponseObject.fromJson(e as Map<String, dynamic>),
|
);
|
||||||
)
|
return val;
|
||||||
.toList(),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$AutocompleteResponseToJson(
|
Map<String, dynamic> _$AutocompleteResponseToJson(
|
||||||
AutocompleteResponse instance,
|
AutocompleteResponse instance,
|
||||||
@@ -22,15 +21,18 @@ Map<String, dynamic> _$AutocompleteResponseToJson(
|
|||||||
|
|
||||||
AutocompleteResponseObject _$AutocompleteResponseObjectFromJson(
|
AutocompleteResponseObject _$AutocompleteResponseObjectFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => AutocompleteResponseObject(
|
) => $checkedCreate('AutocompleteResponseObject', json, ($checkedConvert) {
|
||||||
json['id'] as String,
|
final val = AutocompleteResponseObject(
|
||||||
json['label'] as String,
|
$checkedConvert('id', (v) => v as String),
|
||||||
json['icon'] as String?,
|
$checkedConvert('label', (v) => v as String),
|
||||||
json['source'] as String?,
|
$checkedConvert('icon', (v) => v as String?),
|
||||||
json['status'] as String?,
|
$checkedConvert('source', (v) => v as String?),
|
||||||
json['subline'] as String?,
|
$checkedConvert('status', (v) => _statusFromJson(v)),
|
||||||
json['shareWithDisplayNameUniqe'] as String?,
|
$checkedConvert('subline', (v) => v as String?),
|
||||||
);
|
$checkedConvert('shareWithDisplayNameUnique', (v) => v as String?),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$AutocompleteResponseObjectToJson(
|
Map<String, dynamic> _$AutocompleteResponseObjectToJson(
|
||||||
AutocompleteResponseObject instance,
|
AutocompleteResponseObject instance,
|
||||||
@@ -41,5 +43,5 @@ Map<String, dynamic> _$AutocompleteResponseObjectToJson(
|
|||||||
'source': instance.source,
|
'source': instance.source,
|
||||||
'status': instance.status,
|
'status': instance.status,
|
||||||
'subline': instance.subline,
|
'subline': instance.subline,
|
||||||
'shareWithDisplayNameUniqe': instance.shareWithDisplayNameUniqe,
|
'shareWithDisplayNameUnique': instance.shareWithDisplayNameUnique,
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user