2 Commits
922 changed files with 21269 additions and 49325 deletions
-18
View File
@@ -350,21 +350,3 @@ hs_err_pid*
*.idea* *.idea*
**/.DS_store **/.DS_store
# Fastlane (Play-Store-Upload)
android/fastlane/report.xml
android/fastlane/Preview.html
android/fastlane/play-service-account.json
android/fastlane/README.md.bak
# lokale Screenshots außerhalb der Fastlane-Metadaten
/screenshots/
# Fastlane-Bild-PNGs sind generierte Kopien; kanonisch/versioniert liegen die
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
# bleiben eingecheckt, damit supply die Struktur vorfindet.
android/fastlane/metadata/android/**/images/**/*.png
# Fastlane (App-Store-Upload)
ios/fastlane/report.xml
ios/fastlane/Preview.html
ios/fastlane/app-store-connect-api-key.json
ios/fastlane/screenshots/**/*.jpg
+6 -10
View File
@@ -1,6 +1,6 @@
# MarianumMobile Client # MarianumMobile Client
Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files. Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push).
## Stack ## Stack
@@ -16,8 +16,7 @@ Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. übe
``` ```
lib/ lib/
├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/) ├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/)
├── 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
@@ -52,10 +51,6 @@ 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
@@ -70,12 +65,13 @@ flutter test # Tests (siehe test
| Backend | Pfad | Zweck | | Backend | Pfad | Zweck |
|---------------------------|-----------------------|----------------------------------------| |---------------------------|-----------------------|----------------------------------------|
| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Capabilities, Push | | Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer |
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung | | Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events | | Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten |
| 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 vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu. `test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
-3
View File
@@ -18,9 +18,6 @@ analyzer:
- "**/*.freezed.dart" - "**/*.freezed.dart"
- "lib/firebase_options.dart" - "lib/firebase_options.dart"
- "build/**" - "build/**"
- android/**
- ios/**
- web/**
linter: linter:
rules: rules:
-1
View File
@@ -11,4 +11,3 @@ GeneratedPluginRegistrant.java
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks
.kotlin/
+6 -10
View File
@@ -33,6 +33,10 @@ android {
coreLibraryDesugaringEnabled true coreLibraryDesugaringEnabled true
} }
kotlinOptions {
jvmTarget = '17'
}
sourceSets { sourceSets {
main.java.srcDirs += 'src/main/kotlin' main.java.srcDirs += 'src/main/kotlin'
} }
@@ -43,6 +47,7 @@ android {
targetSdkVersion flutter.targetSdkVersion targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
multiDexEnabled true
} }
buildTypes { buildTypes {
@@ -54,20 +59,11 @@ android {
} }
} }
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter { flutter {
source '../..' source '../..'
} }
dependencies { dependencies {
// Same version as workmanager_android pins — needed to enqueue its implementation 'com.android.support:multidex:2.0.1'
// 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'
} }
@@ -4,10 +4,4 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- Allow cleartext HTTP in debug builds so developers can point the
Marianum-Connect custom endpoint at a local backend (e.g.
http://10.0.2.2:8080 from the Android emulator). Release builds
keep the default cleartext block. -->
<application android:usesCleartextTraffic="true" />
</manifest> </manifest>
+7 -50
View File
@@ -1,15 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:tools="http://schemas.android.com/tools">
<application <application
android:label="Marianum Fulda" android:label="Marianum Fulda"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"> android:dataExtractionRules="@xml/data_extraction_rules"
android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTop"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
@@ -48,40 +48,10 @@
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- Fallback small icon + accent for anything FCM ever renders itself
(e.g. plain notification messages). Launcher mipmaps are unusable
as small icons: Android only draws their alpha silhouette, which
shows up as a solid circle in the status bar. -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_accent" />
<!-- flutter_local_notifications liefert Notification-Action-Taps (Talk:
Antworten / Gelesen) als expliziten Broadcast an diesen Receiver.
Das Plugin deklariert ihn NICHT selbst — ohne diesen Eintrag kommt
der Tap nirgends an und der Background-Handler startet nie. -->
<receiver
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
android:exported="false" />
<!-- background_downloader runs large/long downloads in a foreground
service (WorkManager) so they survive the app being backgrounded and
beat the default 9-minute WorkManager timeout. On API 34+ the
dataSync foreground service type must be declared explicitly here;
see the FOREGROUND_SERVICE_DATA_SYNC permission below. -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<!-- Receiver classes live at the package root (NOT under .widgets) because <!-- Receiver classes live at the package root (NOT under .widgets) because
the home_widget Flutter plugin resolves them as <app-package>.<name>. --> the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
<receiver <receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget" android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
android:enabled="@bool/widget_supported"
android:label="@string/widget_day_label" android:label="@string/widget_day_label"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@@ -94,7 +64,6 @@
<receiver <receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget" android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
android:enabled="@bool/widget_supported"
android:label="@string/widget_week_label" android:label="@string/widget_week_label"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@@ -135,22 +104,10 @@
</intent> </intent>
</queries> </queries>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- Android 13+ runtime notification permission. Requested at login via
FirebaseMessaging.requestPermission(); without this declaration the
locally rendered push notifications are silently dropped. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- background_downloader foreground service for large/long downloads that
must keep running while the app is backgrounded. DATA_SYNC is the
required type declaration on Android 14+ (API 34+). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<!-- Workmanager periodic widget refresh needs to reschedule after device <!-- Workmanager periodic widget refresh needs to reschedule after device
reboot, otherwise the widget freezes until the user opens the app. --> reboot, otherwise the widget freezes until the user opens the app. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- Aus open_filex gemergt: die App öffnet nur selbst heruntergeladene Dateien <!-- RMV "in meiner Nähe"-Suche. Coarse reicht (RMV-Suchradius >= 500 m). -->
über dessen FileProvider (content://-URIs) und braucht keinen Zugriff auf <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
geteilte Medien. Entfernt wegen Google-Play "Photo and Video Permissions". --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" tools:node="remove"/>
</manifest> </manifest>
@@ -0,0 +1,25 @@
// 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,38 +1,15 @@
package eu.mhsl.marianum.mobile.client package eu.mhsl.marianum.mobile.client
import android.content.Intent import android.content.Intent
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val widgetChannel = "eu.mhsl.marianum.widget" private val widgetChannel = "eu.mhsl.marianum.widget"
private val shareChannel = "eu.mhsl.marianum.share"
/// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation` /// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation`
/// so the same intent isn't replayed on every resume. /// so the same intent isn't replayed on every resume.
private var pendingTimetableTap: Boolean = false private var pendingTimetableTap: Boolean = false
private val shareActions = setOf(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE)
/// receive_sharing_intent only emits warm shares to a live Dart listener and
/// does not buffer them. After a relaunch into an existing task the share
/// arrives via onNewIntent before Dart subscribed, so it is held here until
/// Dart reports `listenerReady`.
private var shareListenerReady = false
private var bufferedShare: Intent? = null
override fun onCreate(savedInstanceState: Bundle?) {
// Android replays the original launch intent when the activity is
// recreated from recents or after process death. A share or widget tap
// that was already handled would otherwise run again — for shares with
// temp files that are long gone.
val replayed = savedInstanceState != null ||
(intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0
if (replayed && (intent.action in shareActions || isWidgetTap(intent))) {
intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
}
super.onCreate(savedInstanceState)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -49,35 +26,17 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
shareChannel
).setMethodCallHandler { call, result ->
when (call.method) {
"listenerReady" -> {
shareListenerReady = true
bufferedShare?.let { flutterEngine.activityControlSurface.onNewIntent(it) }
bufferedShare = null
result.success(null)
}
else -> result.notImplemented()
}
}
consumeIntentData(intent) consumeIntentData(intent)
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
if (!shareListenerReady && intent.action in shareActions) {
bufferedShare = intent
}
consumeIntentData(intent) consumeIntentData(intent)
} }
private fun isWidgetTap(intent: Intent?): Boolean =
intent?.getBooleanExtra("widget_open_timetable", false) == true
private fun consumeIntentData(intent: Intent?) { private fun consumeIntentData(intent: Intent?) {
if (isWidgetTap(intent)) pendingTimetableTap = true if (intent?.getBooleanExtra("widget_open_timetable", false) == true) {
pendingTimetableTap = true
}
} }
} }
@@ -4,7 +4,6 @@ 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
/** /**
@@ -12,11 +11,6 @@ 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,15 +4,9 @@ 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, DUTY; REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT;
companion object { companion object {
fun fromWire(raw: String?): WidgetLessonStatus = when (raw) { fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
@@ -22,7 +22,6 @@ enum class WidgetLessonStatus {
"irregular" -> IRREGULAR "irregular" -> IRREGULAR
"teacherChanged" -> TEACHER_CHANGED "teacherChanged" -> TEACHER_CHANGED
"event" -> EVENT "event" -> EVENT
"duty" -> DUTY
else -> REGULAR else -> REGULAR
} }
} }
@@ -34,15 +33,10 @@ 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,
) )
@@ -54,12 +48,6 @@ data class WidgetPeriod(
val virtualEndMinutes: Int, val virtualEndMinutes: Int,
) )
data class WidgetDayInfo(
val date: Date,
val isHoliday: Boolean,
val holidayName: String?,
)
data class WidgetTimetableData( data class WidgetTimetableData(
val fetchedAt: Date, val fetchedAt: Date,
val anchorDate: Date, val anchorDate: Date,
@@ -67,8 +55,6 @@ data class WidgetTimetableData(
val periods: List<WidgetPeriod>, val periods: List<WidgetPeriod>,
val isHoliday: Boolean, val isHoliday: Boolean,
val holidayName: String?, val holidayName: String?,
// Week payload only; empty for the day payload.
val days: List<WidgetDayInfo>,
) )
object WidgetDataParser { object WidgetDataParser {
@@ -93,11 +79,6 @@ object WidgetDataParser {
} }
} }
private fun parseColor(raw: String?): Int? {
if (raw == null || !Regex("#[0-9A-Fa-f]{6}").matches(raw)) return null
return 0xFF000000.toInt() or raw.substring(1).toInt(16)
}
fun parse(json: String?): WidgetTimetableData? { fun parse(json: String?): WidgetTimetableData? {
if (json.isNullOrEmpty()) return null if (json.isNullOrEmpty()) return null
return try { return try {
@@ -119,7 +100,6 @@ object WidgetDataParser {
originalTeacher = obj.stringOrNull("originalTeacher"), originalTeacher = obj.stringOrNull("originalTeacher"),
status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")), status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")),
customColor = obj.stringOrNull("customColor"), customColor = obj.stringOrNull("customColor"),
color = parseColor(obj.stringOrNull("color")),
siblingCount = obj.optInt("siblingCount", 0), siblingCount = obj.optInt("siblingCount", 0),
) )
} }
@@ -138,18 +118,6 @@ object WidgetDataParser {
) )
} }
} }
val daysArray = root.optJSONArray("days")
val days = mutableListOf<WidgetDayInfo>()
if (daysArray != null) {
for (i in 0 until daysArray.length()) {
val obj = daysArray.optJSONObject(i) ?: continue
days += WidgetDayInfo(
date = parseDate(obj.stringOrNull("date")) ?: continue,
isHoliday = obj.optBoolean("isHoliday", false),
holidayName = obj.stringOrNull("holidayName"),
)
}
}
WidgetTimetableData( WidgetTimetableData(
fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(), fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(),
anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(), anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(),
@@ -157,7 +125,6 @@ object WidgetDataParser {
periods = periods, periods = periods,
isHoliday = root.optBoolean("isHoliday", false), isHoliday = root.optBoolean("isHoliday", false),
holidayName = root.stringOrNull("holidayName"), holidayName = root.stringOrNull("holidayName"),
days = days,
) )
} catch (_: JSONException) { } catch (_: JSONException) {
null null
@@ -1,40 +0,0 @@
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)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Status-bar small icon: monochrome Marianum M, ported from
marianum_m_watermark.xml (same path + nested transforms). Android renders
only the alpha channel of small icons, so the fill is hard white and never
tinted at runtime. 24dp with the source's non-square viewport — the system
fits it into the icon bounds.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="70.000168"
android:viewportHeight="82.227348">
<group
android:scaleX="0.26458333"
android:scaleY="0.26458334"
android:translateX="107.44411"
android:translateY="-80.482198">
<group
android:translateX="-749.41293"
android:translateY="290.52252">
<group
android:scaleX="0.13333333"
android:scaleY="-0.13333333"
android:translateX="0"
android:translateY="632.14667">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 3499.67,4594.33 c -16.43,-106.19 -29.71,-199.97 -43.79,-293.49 86.83,-19 138.5,-27.61 223.38,-43.82 63.81,-12.18 175.24,-20.4 179.64,-83.23 6.46,-92.69 -124.69,-55.41 -188.38,-43.81 -84.33,15.36 -159.13,28.84 -232.2,43.81 -13.68,-60.72 -26.83,-118.68 -39.43,-179.61 -36.76,-178.32 -73.67,-368.16 -105.11,-551.97 18.09,25.66 30.84,42.72 43.8,65.7 66.7,118.26 140.39,245.04 227.76,354.83 33.49,42.05 76.86,94.81 118.31,113.91 98.42,45.36 166.68,-22.2 170.87,-118.28 3.68,-85.28 -23.09,-181.17 -35.08,-275.99 -12.4,-98.19 -22.89,-194.93 -35.03,-275.98 72.44,102.69 147.93,269.64 240.95,381.12 27.51,33 73.55,80.61 118.27,87.62 218.76,34.33 126.58,-312.17 127.05,-473.13 0.4,-144.9 44.01,-255.37 175.21,-271.59 43.02,-5.31 105.84,11.16 112.7,-26.34 8.67,-47.38 -78.15,-60.52 -125.84,-61.28 -291.34,-4.51 -322.06,262.33 -311.01,573.88 -19.85,-18.57 -35.71,-47.53 -52.57,-74.47 -97.59,-155.88 -203.95,-327.22 -297.92,-503.79 -25.93,-48.79 -53.68,-114.7 -135.8,-83.23 -17.27,6.63 -48.25,44.39 -52.56,56.96 -19.58,57.19 1.55,137.42 8.76,205.89 21.54,203.72 57.81,389.09 78.87,587.01 -26.3,0.51 -43.93,-30.07 -56.96,-48.2 -46.9,-65.27 -86.02,-140.76 -127.04,-214.64 -52.84,-95.15 -108.23,-192.84 -157.71,-293.52 -75.25,-153.09 -188.6,-501.89 -242.12,-678.81 -8.67,-28.67 -17.7,-58.08 -26.3,-87.64 -7.48,-25.72 -10.39,-57.68 -35.05,-74.46 -100.02,18.93 -89.71,104.89 -70.09,205.9 47.35,243.43 170.89,706.45 211.48,946.04 -72.97,-70.97 -153.99,-207.41 -289.14,-236.55 -136.47,-29.44 -217.95,47.68 -271.6,122.66 -17.14,23.96 -41.43,49.54 -26.29,78.84 83.96,35.51 113.37,-65.2 197.15,-74.47 22.65,-2.5 54.56,2.4 74.46,8.78 132.4,42.34 237.57,218.76 297.87,346.07 74.16,156.45 125.32,330.5 148.95,490.64 -65.71,11.4 -142.96,22.15 -219.25,36.52 -109.8,20.72 -158.81,10.75 -201.29,59.86 9.15,41.95 41.41,60.8 70.1,83.24 126.26,-16.84 252.45,-33.77 372.36,-56.97 20.43,89.25 51.98,218.51 74.45,311.05 40.53,26.02 88.88,-8.43 105.17,-35.06 z" />
</group>
</group>
</group>
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Icons "coffee_outlined", matches the in-app period ruler. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18.52 3H6C4.92 3 3.98 3.89 3.98 5.02V10.69C3.98 14.53 6.94 17.91 10.78 18C14.72 18.14 18 14.95 18 11.02V9.98H18.52C20.44 9.98 21.98 8.44 21.98 6.52C21.98 4.55 20.44 3 18.52 3ZM15.98 5.02V8.02H6V5.02H15.98ZM15.98 9.98V11.02C15.98 13.78 13.78 15.98 11.02 15.98C8.25 15.98 6 13.78 6 11.02V9.98ZM18.52 8.02H18V5.02H18.52C19.31 5.02 20.02 5.67 20.02 6.52C20.02 7.31 19.31 8.02 18.52 8.02ZM3.98 18.98H20.02V21H3.98V18.98Z" />
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Icons "event_outlined", matches the in-app holiday region. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18.98 3.98H18V2.02H15.98V3.98H8.02V2.02H6V3.98H5.02C3.89 3.98 3 4.92 3 6V20.02C3 21.09 3.89 21.98 5.02 21.98H18.98C20.11 21.98 21 21.09 21 20.02V6C21 4.92 20.11 3.98 18.98 3.98ZM18.98 20.02H5.02V9.98H18.98V20.02ZM18.98 8.02H5.02V6H18.98V8.02ZM12 12.98H17.02V18H12V12.98Z" />
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Icons "restaurant", matches the in-app break region. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11.02 9H9V2.02H6.98V9H5.02V2.02H3V9C3 11.11 4.64 12.84 6.75 12.98V21.98H9.23V12.98C11.34 12.84 12.98 11.11 12.98 9V2.02H11.02V9ZM15.98 6V14.02H18.52V21.98H21V2.02C18.23 2.02 15.98 4.22 15.98 6Z" />
</vector>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Tinted at runtime with the tile colour resolved in Dart. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="7dp" />
<solid android:color="@android:color/white" />
</shape>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_cancelled" />
<stroke android:width="1.5dp" android:color="#C8FF0000" />
</shape>
</item>
<item
android:left="3dp"
android:top="3dp"
android:right="3dp"
android:bottom="3dp"
android:drawable="@drawable/widget_lesson_cancelled_x" />
</layer-list>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval"> android:shape="rectangle">
<solid android:color="@android:color/white" /> <corners android:radius="6dp" />
<solid android:color="#FF2196F3" />
</shape> </shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF4CAF50" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FFEF6C00" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF993333" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_irregular" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_ongoing" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_past" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_regular" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_teacher_changed" />
</shape>
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<corners android:radius="7dp" />
<solid android:color="@android:color/transparent" />
<stroke android:width="2dp" android:color="#C8F44336" />
</shape>
</item>
<item
android:left="2dp"
android:top="2dp"
android:right="2dp"
android:bottom="2dp"
android:drawable="@drawable/widget_lesson_cancelled_x" />
</layer-list>
@@ -5,7 +5,7 @@
android:viewportWidth="100" android:viewportWidth="100"
android:viewportHeight="100"> android:viewportHeight="100">
<path <path
android:strokeColor="#C8F44336" android:strokeColor="#C8FF0000"
android:strokeWidth="2.5" android:strokeWidth="5"
android:pathData="M0,0 L100,100 M100,0 L0,100" /> android:pathData="M0,0 L100,100 M100,0 L0,100" />
</vector> </vector>
@@ -3,14 +3,6 @@
android:id="@+id/widget_break_block_root" android:id="@+id/widget_break_block_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="14dp" android:layout_height="14dp"
android:layout_marginTop="0dp"> android:layout_marginTop="0dp"
android:layout_marginStart="2dp"
<ImageView android:layout_marginEnd="2dp" />
android:id="@+id/widget_break_block_icon"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_break_label_root"
android:layout_width="match_parent"
android:layout_height="14dp"
android:layout_marginTop="0dp">
<ImageView
android:id="@+id/widget_break_label_icon"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:src="@drawable/widget_icon_coffee" />
</FrameLayout>
+13 -40
View File
@@ -1,18 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind <!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
the actual widget content without disturbing the LinearLayout flow. the actual widget content without disturbing the LinearLayout flow. -->
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
height from them, so they must not follow the system font scale. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root" android:id="@+id/widget_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/app_widget_background" android:background="@drawable/app_widget_background">
tools:ignore="SpUsage,SmallSp">
<!-- Width/height/end+bottom margins are overridden in the renderer so <!-- Bottom-leading so the mark looks like it's peeking out of the lower
the mark scales with the widget size. --> left corner. Width/height/start+bottom margins are overridden in the
renderer so the mark scales with the widget size. -->
<ImageView <ImageView
android:id="@+id/widget_watermark" android:id="@+id/widget_watermark"
android:layout_width="160dp" android:layout_width="160dp"
@@ -28,68 +26,43 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:paddingStart="8dp" android:padding="10dp">
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="20dp" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
<TextView <TextView
android:id="@+id/widget_day_title" android:id="@+id/widget_day_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_primary"
android:maxLines="1" android:singleLine="true"
android:ellipsize="end" android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="9dp"
android:autoSizeMaxTextSize="14dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="Heute · 08.05." /> tools:text="Heute · 08.05." />
<TextView <TextView
android:id="@+id/widget_day_subtitle" android:id="@+id/widget_day_subtitle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="6dp" android:textSize="10sp"
android:textSize="10dp"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" /> tools:text="Stand: 14:32" />
</LinearLayout> </LinearLayout>
<!-- Narrow day widgets can't fit title and freshness side by side —
the renderer moves the freshness label down here instead. -->
<TextView
android:id="@+id/widget_day_subtitle_below"
android:layout_width="match_parent"
android:layout_height="12dp"
android:visibility="gone"
android:gravity="start|center_vertical"
android:textSize="9dp"
android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginTop="4dp" android:layout_marginTop="6dp"
android:orientation="horizontal"> android:orientation="horizontal">
<FrameLayout <FrameLayout
android:id="@+id/widget_day_time_labels" android:id="@+id/widget_day_time_labels"
android:layout_width="30dp" android:layout_width="32dp"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
@@ -1,11 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Static picker preview of the day widget, laid out like WidgetRenderer's output. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/widget_background" android:background="@color/widget_background">
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
<ImageView <ImageView
android:layout_width="160dp" android:layout_width="160dp"
@@ -23,126 +20,164 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:paddingStart="8dp" android:padding="10dp">
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<TextView <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="20dp" android:layout_height="wrap_content"
android:gravity="start|center_vertical" android:orientation="horizontal"
android:includeFontPadding="false" android:gravity="center_vertical">
android:textSize="14dp" <TextView
android:textStyle="bold" android:layout_width="0dp"
android:textColor="@color/widget_text_primary" android:layout_height="wrap_content"
android:maxLines="1" android:layout_weight="1"
android:text="Heute · 06.05." /> android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="Heute · 06.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginTop="4dp" android:orientation="horizontal"
android:orientation="horizontal"> android:layout_marginTop="6dp">
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent"> <FrameLayout
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> android:layout_width="32dp"
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp"> android:layout_height="match_parent">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" /> <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="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:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
</FrameLayout> android:layout_marginTop="22dp">
<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"
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp"> android:textSize="11sp" android:textStyle="bold"
<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" /> android:textColor="@color/widget_text_primary" android:text="1." />
<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="wrap_content" android:layout_height="wrap_content"
<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" /> android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
</FrameLayout> </LinearLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" /> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="72dp"> android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
<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" /> android:layout_marginTop="50dp">
<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="wrap_content" android:layout_height="wrap_content"
<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" /> android:textSize="11sp" android:textStyle="bold"
</FrameLayout> android:textColor="@color/widget_text_primary" android:text="2." />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" 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="14dp" android:layout_marginTop="108dp"> android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
<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" /> </LinearLayout>
</FrameLayout> <LinearLayout android:layout_width="match_parent" 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" android:gravity="end" android:paddingEnd="3dp"
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="122dp"> android:layout_marginTop="79dp">
<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="wrap_content" 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="4" /> android:textSize="11sp" android:textStyle="bold"
<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" /> android:textColor="@color/widget_text_primary" android:text="3." />
</FrameLayout> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" /> android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="158dp"> </LinearLayout>
<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" /> <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="5" /> 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="12:10" /> android:layout_marginTop="115dp">
</FrameLayout> <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="36dp" android:layout_marginTop="194dp"> android:textColor="@color/widget_text_primary" android:text="4." />
<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="wrap_content" 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="6" /> android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
<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" /> </LinearLayout>
</FrameLayout> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" /> android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="230dp"> android:layout_marginTop="142dp">
<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="wrap_content" 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="7" /> android:textSize="11sp" android:textStyle="bold"
<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" /> android:textColor="@color/widget_text_primary" android:text="5." />
</FrameLayout> <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>
<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
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> android:layout_width="0dp"
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" /> android:layout_height="match_parent"
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" /> android:layout_weight="1">
<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"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="29dp" android:background="@color/widget_divider" />
<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 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="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
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" /> android:layout_width="match_parent"
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" /> android:layout_height="55dp"
<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"> android:layout_marginTop="83dp"
<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_marginStart="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" /> android:layout_marginEnd="2dp"
<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" /> android:padding="3dp"
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" /> android:background="@drawable/widget_lesson_block_regular">
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="DE-GK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="9sp" android:textColor="#CCFFFFFF" android:text="B11" />
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp"> <FrameLayout
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" /> android:layout_width="match_parent"
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" /> 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="SCHN" /> android:layout_marginTop="143dp"
</LinearLayout> android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_cancelled">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="BIO" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp"> <FrameLayout
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" /> android:layout_width="match_parent"
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" /> android:layout_height="22dp"
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" /> android:layout_marginTop="170dp"
</LinearLayout> android:layout_marginStart="2dp"
</FrameLayout> android:layout_marginEnd="2dp"
<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:padding="3dp"
<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:background="@drawable/widget_lesson_block_teacher_changed">
<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"
<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: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="WEB" /> android:textColor="@android:color/white" android:text="GE" />
</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>
@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
holiday name rotated along the column. The name's width is set by the
renderer — after the rotation it is the length along the column. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_holiday_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:ignore="SpUsage,SmallSp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/widget_holiday_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:src="@drawable/widget_icon_event" />
<TextView
android:id="@+id/widget_holiday_label"
android:layout_width="match_parent"
android:layout_height="12dp"
android:layout_marginTop="2dp"
android:paddingStart="2dp"
android:paddingEnd="2dp"
android:gravity="center"
android:text="@string/widget_school_free"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="6dp"
android:autoSizeMaxTextSize="9dp"
android:autoSizeStepGranularity="0.5dp" />
</LinearLayout>
<TextView
android:id="@+id/widget_holiday_name"
android:layout_width="120dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:rotation="90"
android:gravity="center"
android:textStyle="bold"
android:letterSpacing="0.1"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:ellipsize="end"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="7dp"
android:autoSizeMaxTextSize="11dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="Herbstferien" />
</FrameLayout>
@@ -1,113 +1,90 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Mirrors AppointmentTile: title on top, room and teacher stacked below,
each line shrinking on its own to fit the tile width. Text sizes are dp
(not sp) because the line boxes are sized in dp by the renderer. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_lesson_block_root" android:id="@+id/widget_lesson_block_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="40dp" android:layout_height="40dp"
android:layout_marginStart="1dp" android:layout_marginStart="2dp"
android:layout_marginEnd="1dp" android:layout_marginEnd="2dp"
android:layout_marginTop="0dp" android:layout_marginTop="0dp"
android:background="@drawable/widget_lesson_block" android:paddingStart="7dp"
tools:ignore="SpUsage,SmallSp"> android:paddingEnd="7dp"
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:background="@drawable/widget_lesson_block_regular">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="horizontal"
android:paddingStart="4dp" android:gravity="top"
android:paddingEnd="4dp" android:baselineAligned="false">
android:paddingTop="2dp"
android:paddingBottom="2dp"> <TextView
android:id="@+id/widget_lesson_subject"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold"
android:textColor="@android:color/white"
android:maxLines="1"
android:ellipsize="end"
android:gravity="top|start"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="MA-LK" />
<LinearLayout <LinearLayout
android:id="@+id/widget_lesson_title_row" android:id="@+id/widget_lesson_secondary_stack"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:orientation="horizontal" android:layout_weight="1"
android:baselineAligned="false"> android:orientation="vertical"
android:gravity="top|end"
android:layout_marginStart="4dp">
<TextView <TextView
android:id="@+id/widget_lesson_subject" android:id="@+id/widget_lesson_room"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="14dp" android:layout_height="12dp"
android:layout_weight="1" android:textColor="#CCFFFFFF"
android:fontFamily="sans-serif-medium"
android:textColor="@android:color/white"
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="start|center_vertical" android:gravity="end|top"
android:includeFontPadding="false" android:includeFontPadding="false"
android:autoSizeTextType="uniform" android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="6.5dp" android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="15dp" android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="0.5dp" android:autoSizeStepGranularity="1sp"
tools:text="MA-LK" /> tools:text="A12" />
<!-- Only for tiles too short for a body line below the title. -->
<TextView <TextView
android:id="@+id/widget_lesson_inline" android:id="@+id/widget_lesson_teacher"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="12dp"
android:layout_gravity="center_vertical" android:textColor="#B3FFFFFF"
android:layout_marginStart="3dp"
android:textColor="@android:color/white"
android:textSize="7dp"
android:maxLines="1" android:maxLines="1"
android:ellipsize="end"
android:gravity="end|top"
android:includeFontPadding="false" android:includeFontPadding="false"
android:visibility="gone" android:autoSizeTextType="uniform"
tools:text="B11" /> android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="Müller" />
</LinearLayout> </LinearLayout>
<TextView
android:id="@+id/widget_lesson_room"
android:layout_width="match_parent"
android:layout_height="10dp"
android:textColor="@android:color/white"
android:maxLines="1"
android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5.5dp"
android:autoSizeMaxTextSize="10dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="A12" />
<TextView
android:id="@+id/widget_lesson_teacher"
android:layout_width="match_parent"
android:layout_height="10dp"
android:textColor="@android:color/white"
android:maxLines="1"
android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5.5dp"
android:autoSizeMaxTextSize="10dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="Müller" />
</LinearLayout> </LinearLayout>
<FrameLayout
android:id="@+id/widget_lesson_cancelled"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_lesson_cancelled_overlay"
android:visibility="gone" />
<TextView <TextView
android:id="@+id/widget_lesson_sibling_badge" android:id="@+id/widget_lesson_sibling_badge"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|end" android:layout_gravity="bottom|start"
android:layout_marginEnd="4dp"
android:layout_marginBottom="2dp"
android:textStyle="bold" android:textStyle="bold"
android:textSize="9dp" android:textSize="12sp"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:maxLines="1" android:maxLines="1"
android:includeFontPadding="false" android:includeFontPadding="false"
@@ -1,50 +1,31 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- One period cell of the ruler, mirroring the in-app _PeriodLabel: number <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
centred, start time above, end time below. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_time_label_root" android:id="@+id/widget_time_label_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="36dp" android:layout_height="wrap_content"
android:layout_marginTop="0dp" android:layout_marginTop="0dp"
tools:ignore="SpUsage,SmallSp"> android:orientation="vertical"
android:gravity="end"
android:paddingEnd="3dp">
<TextView <TextView
android:id="@+id/widget_time_label_start" android:id="@+id/widget_time_label_time"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="top" android:textSize="9sp"
android:layout_marginTop="2dp" android:textColor="@color/widget_text_primary"
android:gravity="center_horizontal" android:singleLine="true"
android:textSize="7dp" android:lineSpacingExtra="-2dp"
android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="07:55" /> tools:text="07:55" />
<TextView <TextView
android:id="@+id/widget_time_label_number" android:id="@+id/widget_time_label_number"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_vertical" android:textSize="7sp"
android:gravity="center_horizontal"
android:fontFamily="sans-serif-medium"
android:textSize="11dp"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="1" />
<TextView
android:id="@+id/widget_time_label_end"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="2dp"
android:gravity="center_horizontal"
android:textSize="7dp"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_secondary"
android:maxLines="1" android:singleLine="true"
android:includeFontPadding="false" android:lineSpacingExtra="-2dp"
tools:text="08:40" /> tools:text="1." />
</FrameLayout> </LinearLayout>
+18 -36
View File
@@ -1,18 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
the actual widget content without disturbing the LinearLayout flow.
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
height from them, so they must not follow the system font scale. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root" android:id="@+id/widget_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/app_widget_background" android:background="@drawable/app_widget_background">
tools:ignore="SpUsage,SmallSp">
<!-- Width/height/end+bottom margins are overridden in the renderer so
the mark scales with the widget size. -->
<ImageView <ImageView
android:id="@+id/widget_watermark" android:id="@+id/widget_watermark"
android:layout_width="160dp" android:layout_width="160dp"
@@ -28,53 +21,42 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:paddingStart="8dp" android:padding="10dp">
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="20dp" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
<TextView <TextView
android:id="@+id/widget_week_title" android:id="@+id/widget_week_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_primary"
android:maxLines="1" android:singleLine="true"
android:ellipsize="end" android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="9dp"
android:autoSizeMaxTextSize="14dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="KW 19 · 06.05.–10.05." /> tools:text="KW 19 · 06.05.–10.05." />
<TextView <TextView
android:id="@+id/widget_week_subtitle" android:id="@+id/widget_week_subtitle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="6dp" android:textSize="10sp"
android:textSize="10dp"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" /> tools:text="Stand: 14:32" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="32dp" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:orientation="horizontal"
android:orientation="horizontal"> android:layout_marginTop="6dp">
<FrameLayout <FrameLayout
android:layout_width="30dp" android:layout_width="28dp"
android:layout_height="match_parent" /> android:layout_height="wrap_content" />
<FrameLayout <FrameLayout
android:layout_width="1dp" android:layout_width="1dp"
@@ -84,7 +66,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_mon" android:id="@+id/widget_week_header_mon"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -95,7 +77,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_tue" android:id="@+id/widget_week_header_tue"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -106,7 +88,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_wed" android:id="@+id/widget_week_header_wed"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -117,7 +99,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_thu" android:id="@+id/widget_week_header_thu"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -128,7 +110,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_fri" android:id="@+id/widget_week_header_fri"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_weight="1" /> android:layout_weight="1" />
</LinearLayout> </LinearLayout>
@@ -141,7 +123,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_time_labels" android:id="@+id/widget_week_time_labels"
android:layout_width="30dp" android:layout_width="28dp"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
@@ -1,35 +1,28 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
today inside an accent circle. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_week_day_header_root" android:id="@+id/widget_week_day_header_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center" android:gravity="center"
tools:ignore="SpUsage,SmallSp"> android:paddingTop="2dp"
android:paddingBottom="3dp">
<TextView <TextView
android:id="@+id/widget_week_day_header_weekday" android:id="@+id/widget_week_day_header_weekday"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium" android:textSize="11sp"
android:textSize="9dp" android:textStyle="bold"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_primary"
android:maxLines="1" tools:text="Mo" />
android:includeFontPadding="false"
tools:text="MO." />
<TextView <TextView
android:id="@+id/widget_week_day_header_date" android:id="@+id/widget_week_day_header_date"
android:layout_width="18dp" android:layout_width="wrap_content"
android:layout_height="18dp" android:layout_height="wrap_content"
android:layout_marginTop="1dp" android:textSize="9sp"
android:gravity="center" android:textColor="@color/widget_text_secondary"
android:textSize="11dp" tools:text="06.05." />
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="6" />
</LinearLayout> </LinearLayout>
@@ -1,11 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Static picker preview of the week widget, laid out like WidgetRenderer's output. --> <!-- Static preview for the week widget. Five day columns with two short
demo blocks each so the structure is recognisable in the picker. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/widget_background" android:background="@color/widget_background">
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
<ImageView <ImageView
android:layout_width="200dp" android:layout_width="200dp"
@@ -23,312 +22,124 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:paddingStart="8dp" android:padding="10dp">
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<TextView
android:layout_width="match_parent"
android:layout_height="20dp"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:textSize="14dp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:text="KW 19 · 04.05.–08.05." />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="32dp" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:orientation="horizontal"
android:orientation="horizontal"> android:gravity="center_vertical">
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent" /> <TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="KW 19 · 06.05.–10.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<!-- Day-name + date row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp">
<FrameLayout android:layout_width="20dp" android:layout_height="wrap_content" />
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MO." /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mo" />
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="06.05." />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DI." /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Di" />
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="07.05." />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MI." /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mi" />
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="08.05." />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DO." /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Do" />
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="09.05." />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center"> <LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="FR." /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Fr" />
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="8" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="10.05." />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<!-- Time grid: time-label column + 5 day columns -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginTop="2dp" android:orientation="horizontal"
android:orientation="horizontal"> android:layout_marginTop="2dp">
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent"> <FrameLayout android:layout_width="20dp" android:layout_height="match_parent">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="0dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="08" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp"> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="36dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="10" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="72dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="12" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" /> </FrameLayout>
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Mon -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
</FrameLayout> </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="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="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="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_cancelled">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="BIO" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="60dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="DE" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#000000">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
</LinearLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" />
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Wed -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="match_parent" android:layout_height="32dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#26A69A">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="SPO" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="TH1" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BAU" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#8F19B3">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PoWi" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A105" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="HOF" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="84.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#29639B">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Thu -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_teacher_changed">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="GE" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="PH" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#E91E63">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="CH" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N305" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MEI" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="194.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Fri -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#78909C">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A301" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LAN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#7E57C2">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MU" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="M1" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SIN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#F4511E">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="KU" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="K2" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MAL" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#FFA000">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="LA" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A108" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="CIC" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
</LinearLayout> </LinearLayout>
@@ -4,6 +4,4 @@
<color name="widget_text_primary">#FFF1F1F1</color> <color name="widget_text_primary">#FFF1F1F1</color>
<color name="widget_text_secondary">#FFB0B0B0</color> <color name="widget_text_secondary">#FFB0B0B0</color>
<color name="widget_divider">#33FFFFFF</color> <color name="widget_divider">#33FFFFFF</color>
<color name="widget_preview_accent">#FFFFB3AF</color>
<color name="widget_preview_break">#32FFB3AF</color>
</resources> </resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="widget_supported">true</bool>
</resources>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Brand accent for notifications; mirrors LightAppTheme.marianumRed
(Color.fromARGB(255, 153, 51, 51)). Used as the FCM default
notification color and must stay in sync with the Dart constant. -->
<color name="notification_accent">#993333</color>
</resources>
@@ -6,7 +6,6 @@
<string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string> <string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string>
<string name="widget_no_lessons">Keine Stunden</string> <string name="widget_no_lessons">Keine Stunden</string>
<string name="widget_holiday">Ferien</string> <string name="widget_holiday">Ferien</string>
<string name="widget_school_free">Schulfrei</string>
<string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string> <string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string>
<string name="widget_loading">Lade…</string> <string name="widget_loading">Lade…</string>
<string name="widget_status_label">Stand: %1$s</string> <string name="widget_status_label">Stand: %1$s</string>
@@ -1,9 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Status colors mirror lib/view/pages/timetable/data/lesson_color.dart
exactly so widget tiles match the in-app calendar. -->
<color name="widget_lesson_regular">#FF993333</color>
<color name="widget_lesson_ongoing">#FFC83333</color>
<color name="widget_lesson_past">#FF993333</color>
<color name="widget_lesson_cancelled">#FF000000</color>
<color name="widget_lesson_irregular">#FF8F19B3</color>
<color name="widget_lesson_teacher_changed">#FF29639B</color>
<color name="widget_lesson_event">#FF2E7D32</color>
<color name="widget_background">#FFFCF7F5</color> <color name="widget_background">#FFFCF7F5</color>
<color name="widget_text_primary">#FF111111</color> <color name="widget_text_primary">#FF111111</color>
<color name="widget_text_secondary">#FF555555</color> <color name="widget_text_secondary">#FF555555</color>
<color name="widget_divider">#22000000</color> <color name="widget_divider">#22000000</color>
<color name="widget_preview_accent">#FF904A47</color>
<color name="widget_preview_break">#32904A47</color>
</resources> </resources>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- WidgetRenderer positions every tile with RemoteViews calls that only
exist from Android 12 (API 31). Below that the widgets are not
offered at all rather than showing up empty. -->
<bool name="widget_supported">false</bool>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext for the MarianumConnect test instance only. Once the
production URL with HTTPS is live, drop this domain-config entry. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">muelleel.ddns.net</domain>
</domain-config>
</network-security-config>
+3 -23
View File
@@ -5,25 +5,10 @@ allprojects {
} }
} }
rootProject.layout.buildDirectory = file('../build') rootProject.buildDir = '../build'
subprojects { subprojects {
project.layout.buildDirectory = rootProject.layout.buildDirectory.dir(project.name) project.buildDir = "${rootProject.buildDir}/${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
@@ -37,11 +22,6 @@ subprojects { sub ->
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
// Some plugins (e.g. app_settings) hard-pin an older compileSdk
// than their transitive AndroidX deps require, which fails the
// AAR metadata check. Align every plugin with the app's
// compileSdk (flutter.compileSdkVersion) so the build passes.
compileSdkVersion 36
} }
} }
sub.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { sub.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
@@ -57,5 +37,5 @@ subprojects {
} }
tasks.register("clean", Delete) { tasks.register("clean", Delete) {
delete rootProject.layout.buildDirectory delete rootProject.buildDir
} }
-6
View File
@@ -1,6 +0,0 @@
package_name("eu.mhsl.marianum.mobile.client")
# Service-Account-JSON für die Google Play Developer API. Pfad über die
# Umgebungsvariable SUPPLY_JSON_KEY setzen (die Datei selbst wird per
# .gitignore nicht eingecheckt).
json_key_file(ENV["SUPPLY_JSON_KEY"] || "fastlane/play-service-account.json")
-29
View File
@@ -1,29 +0,0 @@
default_platform(:android)
# Play-Store-Auslieferung über fastlane supply. Die Screenshots erzeugt
# ../tool/screenshots.sh (Demo-Login + integration_test) direkt in
# metadata/android/<locale>/images/. Texte/Changelogs liegen daneben in
# metadata/android/<locale>/.
platform :android do
desc "Nur Screenshots hochladen (verändert keine Texte/Binaries)"
lane :upload_screenshots do
upload_to_play_store(
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: false,
skip_upload_screenshots: false,
metadata_path: "./fastlane/metadata/android",
)
end
desc "Store-Texte, Changelogs und Screenshots hochladen (keine Binaries)"
lane :upload_metadata do
upload_to_play_store(
skip_upload_apk: true,
skip_upload_aab: true,
metadata_path: "./fastlane/metadata/android",
)
end
end
-2
View File
@@ -1,2 +0,0 @@
# fastlane-Plugins (aktuell keine). Datei vorhanden, damit `fastlane` das
# Plugin-Handling initialisiert.
-35
View File
@@ -1,35 +0,0 @@
# Fastlane – Play-Store-Screenshots & Upload
Schritt 2 der Play-Store-Automatisierung. Die App muss dafür **nicht** neu
gebaut werden – der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte
aus Fixtures.
## 1. Screenshots aufnehmen
Ein Gerät/Emulator (≥ Android 8) per `adb devices` sichtbar, dann aus dem
**Client**-Wurzelverzeichnis:
```bash
tool/screenshots.sh
```
Fährt Phone (1080×1920), 7″ (1200×1920) und 10″ (1600×2560) nacheinander durch
(setzt die Displaygröße per `adb shell wm size/density`) und legt die PNGs in
`metadata/android/de-DE/images/{phone,sevenInch,tenInch}Screenshots/` ab.
- Nur eine Größe: `PROFILES="phone" tool/screenshots.sh`
- Profile-Build (flüssigere Frames): `BUILD_MODE=profile tool/screenshots.sh`
## 2. Hochladen
Service-Account-JSON der Google Play Developer API besorgen und referenzieren:
```bash
export SUPPLY_JSON_KEY=/pfad/zu/play-service-account.json
cd android
fastlane upload_screenshots # nur Bilder
# oder
fastlane upload_metadata # Bilder + Texte/Changelogs, keine Binaries
```
Der Service-Account-Key wird **nicht** eingecheckt (siehe `.gitignore`).
+4 -4
View File
@@ -1,6 +1,6 @@
org.gradle.jvmargs=-Xmx4G org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true android.useAndroidX=true
# This newDsl flag was added automatically by Flutter migrator android.enableJetifier=true
android.newDsl=false android.defaults.buildfeatures.buildconfig=true
# This builtInKotlin flag was added automatically by Flutter migrator android.nonTransitiveRClass=false
android.builtInKotlin=false android.nonFinalResIds=false
+1 -1
View File
@@ -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-9.3.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+3 -3
View File
@@ -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 '9.1.0' apply false id "com.android.application" version '8.13.2' apply false
id "com.android.library" version '9.1.0' apply false id "com.android.library" version '8.13.2' apply false
id "org.jetbrains.kotlin.android" version "2.4.0" apply false id "org.jetbrains.kotlin.android" version "2.2.20" apply false
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0' id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -4,5 +4,4 @@ targets:
json_serializable: json_serializable:
options: options:
explicit_to_json: false explicit_to_json: false
checked: true
generic_argument_factories: true generic_argument_factories: true
-40
View File
@@ -1,40 +0,0 @@
---
#
# 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)
-234
View File
@@ -1,234 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:marianum_mobile/app.dart';
import 'package:marianum_mobile/main.dart' as app;
import 'package:marianum_mobile/routing/app_routes.dart';
import 'package:marianum_mobile/state/app/modules/app_modules.dart';
import 'package:marianum_mobile/state/app/modules/settings/bloc/settings_cubit.dart';
import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
/// Marketing-Screenshot-Lauf für die Stores. Loggt sich über den Demo-Login
/// (`demo@…`, siehe DemoMode) ein und nimmt einen kuratierten Satz Screens auf:
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
/// und Einstellungen. Ausgeführt über `flutter drive` mit
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
/// (Android: Phone / 7″ / 10″) bzw. tool/screenshots_ios.sh (iPhone 6,5″ /
/// iPad 13″).
///
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
/// Fixtures, daher ist der Lauf deterministisch.
Future<void> main() async {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Store-Screenshots über den Demo-Login', (tester) async {
// Der integration_test-Harness installiert eigene Error-Handler. app()
// überschreibt sie in main() durch die produktiven (ClientErrorReporter),
// was die End-of-Test-Buchführung des Harness sprengt (_pendingExceptionDetails).
// Daher vorher sichern und nach main() zurücksetzen.
final harnessFlutterOnError = FlutterError.onError;
final harnessDispatcherOnError = PlatformDispatcher.instance.onError;
_log('main() starten');
await app.main();
FlutterError.onError = harnessFlutterOnError;
PlatformDispatcher.instance.onError = harnessDispatcherOnError;
await tester.pump(const Duration(seconds: 1));
_log('Login');
await _login(tester);
// Großzügig: auf einem frisch angelegten iOS-Simulator dauert der erste
// Post-Login-Aufbau deutlich länger als 20s.
_log('warte auf App-Shell');
await _pumpUntil(
tester,
find.byType(App),
timeout: const Duration(seconds: 60),
);
_log('warte auf Post-Login-Splash-Ende');
await _pumpUntilGone(
tester,
find.byKey(const ValueKey('post-login-splash')),
timeout: const Duration(seconds: 12),
);
await tester.pump(const Duration(seconds: 1));
// Screenshots auf Android brauchen eine einmalige Umwandlung der
// Flutter-Surface in eine lesbare Image-Texture. Erst hier – nach Login und
// Splash –, damit der sichtbare Screen erst kurz vor der Aufnahme „einfriert".
if (Platform.isAndroid) {
_log('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.
await _goTo(tester, Modules.timetable);
_setTheme(tester, ThemeMode.light);
await _capture(tester, binding, '1_stundenplan_hell');
_setTheme(tester, ThemeMode.dark);
await _capture(tester, binding, '2_stundenplan_dunkel');
// Restlicher Satz einheitlich im hellen Theme.
_setTheme(tester, ThemeMode.light);
// 3: Talk-Liste.
await _goTo(tester, Modules.talk);
await _capture(tester, binding, '3_talk_liste');
// 4: Talk-Chat (ersten Chat der Liste öffnen).
await tester.tap(find.byType(ChatTile).first);
await _capture(tester, binding, '4_talk_chat');
_popPushedPage(tester);
await tester.pump(const Duration(milliseconds: 500));
// 5: Dateien.
await _goTo(tester, Modules.files);
await _capture(tester, binding, '5_dateien');
// 6: „Mehr"-Bereich (letzter Tab, hinter den Modul-Tabs).
_goToMore(tester);
await _capture(tester, binding, '6_mehr');
// 7: Einstellungen (Vollbild-Push).
AppRoutes.openSettings(tester.element(find.byType(App)));
await _capture(tester, binding, '7_einstellungen');
_popPushedPage(tester);
_log('fertig');
});
}
void _log(String message) => debugPrint('SHOTS: $message');
Future<void> _login(WidgetTester tester) async {
final loginVisible = await _pumpUntil(
tester,
find.byKey(const Key('login-username-field')),
);
if (!loginVisible) {
_log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login');
return;
}
await tester.enterText(
find.byKey(const Key('login-username-field')),
'demo@screenshots',
);
await tester.enterText(find.byKey(const Key('login-password-field')), 'demo');
await tester.pump();
await tester.tap(find.byKey(const Key('login-submit-button')));
await tester.pump(const Duration(milliseconds: 500));
}
/// Setzt das App-Theme über den [SettingsCubit] (MaterialApp folgt
/// `settings.appTheme`). `val(write: true)` plant den Emit als Microtask.
void _setTheme(WidgetTester tester, ThemeMode mode) {
final context = tester.element(find.byType(App));
context.read<SettingsCubit>().val(write: true).appTheme = mode;
}
void _markTalkPermissionPromptShown(WidgetTester tester) {
final context = tester.element(find.byType(App));
context
.read<SettingsCubit>()
.val(write: true)
.notificationSettings
.talkPermissionPromptShown =
true;
}
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt,
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
Future<void> _goTo(WidgetTester tester, Modules module) async {
final appFinder = find.byType(App);
if (appFinder.evaluate().isEmpty) {
_log('WARN: App-Shell fehlt, kann $module nicht ansteuern');
return;
}
final context = tester.element(appFinder);
if (!AppRoutes.goToTab(context, module)) {
final resolved = AppModule.modules(context)[module];
if (resolved != null) AppRoutes.openModule(context, resolved);
}
}
/// Springt auf den „Mehr"-Tab. Der liegt hinter den Modul-Tabs, sein Index ist
/// also die Anzahl der Bottom-Bar-Module.
void _goToMore(WidgetTester tester) {
final context = tester.element(find.byType(App));
final moreIndex = AppModule.getBottomBarModules(context).length;
app.Main.bottomNavigator.jumpToTab(moreIndex);
}
/// Schließt einen per [AppRoutes] gepushten Vollbild-Screen wieder.
void _popPushedPage(WidgetTester tester) {
final navigator = AppRoutes.rootNavigatorKey.currentState;
if (navigator != null && navigator.canPop()) navigator.pop();
}
/// Lässt die Oberfläche zur Ruhe kommen und legt den Screenshot ab.
Future<void> _capture(
WidgetTester tester,
IntegrationTestWidgetsFlutterBinding binding,
String name,
) async {
// Feste Setzzeit statt „warte auf Spinner-Ende": die Demo-Fixtures kommen
// synchron (keine künstlichen Delays), aber Module wie Dateien/Chat laden erst
// beim Navigieren nach. Eine globale CircularProgressIndicator-Prüfung taugt
// nicht, weil die PersistentTabView alle Tabs am Leben hält und
// Avatar-Platzhalter dauerhaft einen Spinner zeigen. In 500ms-Schritten
// pumpen, damit Timer/Microtasks (u.a. der Theme-Emit) durchlaufen.
await _settle(tester, const Duration(seconds: 6));
_log('-> $name: takeScreenshot');
await binding.takeScreenshot(name);
}
/// Pumpt [duration] in 500ms-Schritten ab, damit die App async Fixtures laden
/// und ihre Frames rendern kann (Ersatz für pumpAndSettle, das an den
/// Dauer-Animationen hängen bleibt).
Future<void> _settle(WidgetTester tester, Duration duration) async {
final steps = duration.inMilliseconds ~/ 500;
for (var i = 0; i < steps; i++) {
await tester.pump(const Duration(milliseconds: 500));
}
}
/// Pumpt in kurzen Schritten, bis [finder] erscheint oder [timeout] abläuft.
/// Gibt zurück, ob [finder] gefunden wurde.
Future<bool> _pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isNotEmpty) return true;
}
return false;
}
/// Gegenstück zu [_pumpUntil]: pumpt, bis [finder] nichts mehr trifft.
Future<bool> _pumpUntilGone(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isEmpty) return true;
}
return false;
}
@@ -1 +0,0 @@
#include "Generated.xcconfig"
@@ -1 +0,0 @@
#include "Generated.xcconfig"
@@ -1 +0,0 @@
#include "Generated.xcconfig"
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>NotificationServiceExtension</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
@@ -1,256 +0,0 @@
import UserNotifications
import Security
/// Notification Service Extension for Nextcloud push-v2.
///
/// Architecture: Nextcloud pushes an E2E-encrypted notification to the
/// MarianumConnect proxy, which forwards it via FCM to this device as an
/// `alert` push with `mutable-content: 1`. That flag makes iOS spin up this
/// extension *before* showing the notification, giving us ~30 s (wall clock,
/// shared with the CPU budget) to decrypt the payload and replace the
/// placeholder alert with the real content.
///
/// The FCM proxy places the two relevant fields at the top level of the APNs
/// payload (mirrored into `request.content.userInfo`):
/// - `subject` base64( RSA-encrypted subject JSON ) — encrypted with THIS
/// device's public key, so we decrypt with our private key.
/// - `signature` base64( SHA-512-with-RSA over the *encrypted* subject bytes )
/// — signed by the per-user server key we stored at registration.
///
/// Key material lives in the shared (App Group) keychain, written by the Dart
/// side via flutter_secure_storage with
/// `IOSOptions(groupId: kPushKeychainGroup, accessibility: first_unlock)`:
/// - `push_device_private_key_pem` crypton PKCS#8-in-"RSA PRIVATE KEY" PEM
/// - `push_server_public_key_pem` SPKI "PUBLIC KEY" PEM (from NC)
///
/// Robustness contract: on ANY failure (missing keys, bad signature, decrypt
/// failure, malformed JSON, timeout) we deliver the untouched placeholder
/// content. We never crash and never deliver empty content.
class NotificationService: UNNotificationServiceExtension {
/// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
/// extension. Wrong value here => keychain reads return nil => placeholder.
private static let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
private static let devicePrivateKeyAccount = "push_device_private_key_pem"
private static let serverPublicKeyAccount = "push_server_public_key_pem"
private static let talkCategoryId = "TALK_MESSAGE"
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
self.bestAttemptContent =
request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
// Any early return delivers the placeholder unchanged — that is the
// intended fallback, never an error path that hides the notification.
let userInfo = content.userInfo
guard
let subjectB64 = userInfo["subject"] as? String,
let signatureB64 = userInfo["signature"] as? String,
let encrypted = Data(base64Encoded: subjectB64),
let signature = Data(base64Encoded: signatureB64)
else {
deliver(content)
return
}
guard let privateKey = loadPrivateKey() else {
NSLog("[NSE] no device private key in keychain, delivering placeholder")
deliver(content)
return
}
// Signature verification is defence-in-depth (the proxy already verified
// it). Skip only if we somehow have no server key; never deliver on a
// *failed* verification.
if let serverKey = loadServerPublicKey() {
if !verifySignature(signature, over: encrypted, with: serverKey) {
NSLog("[NSE] signature verification failed, delivering placeholder")
deliver(content)
return
}
}
guard
let plaintext = decrypt(encrypted, with: privateKey),
let subject = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any]
else {
NSLog("[NSE] could not decrypt/parse subject, delivering placeholder")
deliver(content)
return
}
apply(subject: subject, to: content)
deliver(content)
}
override func serviceExtensionTimeWillExpire() {
// Ran out of time — hand back whatever we have (at worst the placeholder).
if let handler = contentHandler, let content = bestAttemptContent {
handler(content)
}
}
private func deliver(_ content: UNNotificationContent) {
contentHandler?(content)
}
// MARK: - Content shaping
private func apply(subject: [String: Any], to content: UNMutableNotificationContent) {
// Delete pushes normally arrive as silent `background` pushes that never
// invoke this extension. If one reaches us anyway, an NSE cannot suppress
// the banner (iOS always shows *something*), so we leave the placeholder.
// Actual delete cleanup is done by the Flutter app on next open. See
// ios/PUSH_NSE_SETUP.md "Delete-Handling".
let isDelete = (subject["delete"] as? Bool == true)
|| (subject["delete-multiple"] as? Bool == true)
|| (subject["delete-all"] as? Bool == true)
if isDelete {
NSLog("[NSE] delete push reached NSE — cannot suppress, leaving placeholder")
return
}
let app = subject["app"] as? String
let text = subject["subject"] as? String ?? "Neue Benachrichtigung"
let objectId = stringValue(subject["id"])
let nid = subject["nid"]
if app == "spreed" {
// Talk: "Sender: message" -> title = Sender, body = message.
let (sender, message) = splitSender(text)
content.title = sender
content.body = message
content.categoryIdentifier = NotificationService.talkCategoryId
if let token = objectId, !token.isEmpty {
content.threadIdentifier = "talk_\(token)"
var info = content.userInfo
info["chatToken"] = token
if let nid = nid { info["nid"] = nid }
content.userInfo = info
}
} else {
content.title = text
content.body = ""
}
// Badge intentionally left untouched (the app recomputes it from the
// delivered notifications).
}
/// Splits a Talk subject into header and message. nextcloud/spreed's
/// Notifier separates them with a NEWLINE — `"{user}\n{message}"` for 1:1
/// chats, `"{user} in {call}\n{message}"` for groups (verified against
/// spreed; mirrors parseTalkSubject in lib/push/push_subject.dart). The
/// legacy `": "` form is kept as fallback for older payloads.
private func splitSender(_ subject: String) -> (String, String) {
if let range = subject.range(of: "\n") {
let sender = String(subject[..<range.lowerBound])
.trimmingCharacters(in: .whitespaces)
let message = String(subject[range.upperBound...])
if !sender.isEmpty && !message.isEmpty {
return (sender, message)
}
}
if let range = subject.range(of: ": ") {
let sender = String(subject[..<range.lowerBound])
let message = String(subject[range.upperBound...])
if !sender.isEmpty && !message.isEmpty {
return (sender, message)
}
}
return ("Talk", subject)
}
private func stringValue(_ value: Any?) -> String? {
if let s = value as? String { return s }
if let n = value as? NSNumber { return n.stringValue }
return nil
}
// MARK: - Crypto
private func verifySignature(_ signature: Data, over message: Data, with key: SecKey) -> Bool {
var error: Unmanaged<CFError>?
let ok = SecKeyVerifySignature(
key,
.rsaSignatureMessagePKCS1v15SHA512,
message as CFData,
signature as CFData,
&error
)
return ok
}
private func decrypt(_ data: Data, with key: SecKey) -> Data? {
// NC 32 defaults to OAEP(SHA-1); older instances use PKCS#1 v1.5. Try
// OAEP first, fall back to PKCS#1 — mirrors the Dart PushDecryptor.
for algorithm in [SecKeyAlgorithm.rsaEncryptionOAEPSHA1,
SecKeyAlgorithm.rsaEncryptionPKCS1] {
var error: Unmanaged<CFError>?
if let plain = SecKeyCreateDecryptedData(key, algorithm, data as CFData, &error) as Data? {
return plain
}
}
return nil
}
// MARK: - Key loading
private func loadPrivateKey() -> SecKey? {
guard let pem = keychainString(NotificationService.devicePrivateKeyAccount),
let der = PEM.der(fromPem: pem),
// crypton labels it "RSA PRIVATE KEY" but the body is PKCS#8.
let pkcs1 = PEM.pkcs1PrivateKey(fromPkcs8: der)
else { return nil }
return PEM.rsaKey(pkcs1: pkcs1, isPrivate: true)
}
private func loadServerPublicKey() -> SecKey? {
guard let pem = keychainString(NotificationService.serverPublicKeyAccount),
let der = PEM.der(fromPem: pem),
let pkcs1 = PEM.pkcs1PublicKey(fromSpki: der)
else { return nil }
return PEM.rsaKey(pkcs1: pkcs1, isPrivate: false)
}
// MARK: - Keychain
/// Reads a flutter_secure_storage entry from the shared keychain. Must match
/// exactly how flutter_secure_storage_darwin 0.3.2 stores items:
/// kSecClass = kSecClassGenericPassword
/// kSecAttrAccount = the Dart key, verbatim
/// kSecAttrService = (unset — the Dart IOSOptions set no accountName)
/// kSecAttrAccessGroup = the team-prefixed shared keychain group
/// value = raw UTF-8 bytes of the string
private func keychainString(_ account: String) -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
kSecAttrAccessGroup: NotificationService.keychainAccessGroup,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
if status != errSecItemNotFound {
NSLog("[NSE] keychain read '\(account)' failed: \(status)")
}
return nil
}
return String(data: data, encoding: .utf8)
}
}
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
</array>
</dict>
</plist>
-105
View File
@@ -1,105 +0,0 @@
import Foundation
import Security
/// Minimal PEM/DER helpers to turn the crypton-produced PEM strings into
/// `SecKey`s. iOS's `SecKeyCreateWithData` only accepts *raw PKCS#1* DER for
/// RSA, so we must strip the two wrapper formats crypton/Nextcloud use:
///
/// - Private key: crypton's `toPEM()` emits the `RSA PRIVATE KEY` label but the
/// DER body is actually **PKCS#8** `PrivateKeyInfo`
/// (SEQUENCE { INTEGER version, SEQUENCE algId, OCTET STRING pkcs1 }).
/// We extract the OCTET STRING content = the PKCS#1 `RSAPrivateKey`.
///
/// - Public key: `PUBLIC KEY` label, **SPKI** `SubjectPublicKeyInfo`
/// (SEQUENCE { SEQUENCE algId, BIT STRING spki }). We extract the BIT STRING
/// content and drop its leading 0x00 "unused bits" byte = the PKCS#1
/// `RSAPublicKey`.
///
/// Only DER lengths up to 4 bytes are handled — more than enough for RSA-2048.
enum PEM {
/// Strips the PEM armor and returns the base64-decoded DER.
static func der(fromPem pem: String) -> Data? {
let body = pem
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.filter { !$0.hasPrefix("-----") }
.joined()
return Data(base64Encoded: body)
}
/// PKCS#8 `PrivateKeyInfo` -> inner PKCS#1 `RSAPrivateKey`.
static func pkcs1PrivateKey(fromPkcs8 der: Data) -> Data? {
var reader = DERReader(der)
guard let outer = reader.readTLV(), outer.tag == 0x30 else { return nil }
var inner = DERReader(Data(outer.value))
guard let version = inner.readTLV(), version.tag == 0x02 else { return nil } // INTEGER version
guard let algId = inner.readTLV(), algId.tag == 0x30 else { return nil } // SEQUENCE algId
guard let octet = inner.readTLV(), octet.tag == 0x04 else { return nil } // OCTET STRING
return Data(octet.value)
}
/// SPKI `SubjectPublicKeyInfo` -> inner PKCS#1 `RSAPublicKey`.
static func pkcs1PublicKey(fromSpki der: Data) -> Data? {
var reader = DERReader(der)
guard let outer = reader.readTLV(), outer.tag == 0x30 else { return nil }
var inner = DERReader(Data(outer.value))
guard let algId = inner.readTLV(), algId.tag == 0x30 else { return nil } // SEQUENCE algId
guard let bitString = inner.readTLV(), bitString.tag == 0x03 else { return nil } // BIT STRING
var bytes = Array(bitString.value)
guard let first = bytes.first, first == 0x00 else { return nil } // unused-bits count
bytes.removeFirst()
return Data(bytes)
}
/// Builds an RSA `SecKey` from raw PKCS#1 DER.
static func rsaKey(pkcs1 der: Data, isPrivate: Bool) -> SecKey? {
let attributes: [CFString: Any] = [
kSecAttrKeyType: kSecAttrKeyTypeRSA,
kSecAttrKeyClass: isPrivate ? kSecAttrKeyClassPrivate : kSecAttrKeyClassPublic,
kSecAttrKeySizeInBits: 2048,
]
var error: Unmanaged<CFError>?
let key = SecKeyCreateWithData(der as CFData, attributes as CFDictionary, &error)
if key == nil {
NSLog("[NSE] SecKeyCreateWithData failed: \(String(describing: error?.takeRetainedValue()))")
}
return key
}
}
/// Tiny non-recursive DER TLV reader over a byte buffer.
private struct DERReader {
private let bytes: [UInt8]
private var pos = 0
init(_ data: Data) { bytes = [UInt8](data) }
/// Reads one tag-length-value triple, advancing past the value. Returns the
/// tag byte and the value bytes, or nil on a malformed/truncated buffer.
mutating func readTLV() -> (tag: UInt8, value: ArraySlice<UInt8>)? {
guard pos < bytes.count else { return nil }
let tag = bytes[pos]
pos += 1
guard pos < bytes.count else { return nil }
var length = Int(bytes[pos])
pos += 1
if length & 0x80 != 0 {
let numLengthBytes = length & 0x7F
guard numLengthBytes > 0, numLengthBytes <= 4, pos + numLengthBytes <= bytes.count else {
return nil
}
length = 0
for _ in 0..<numLengthBytes {
length = (length << 8) | Int(bytes[pos])
pos += 1
}
}
guard pos + length <= bytes.count else { return nil }
let value = bytes[pos..<pos + length]
pos += length
return (tag, value)
}
}
-312
View File
@@ -1,312 +0,0 @@
# iOS Notification Service Extension (NSE) — Setup & Übergabe
> **Für wen:** Eine spätere (kontextlose) Claude-Session **und** den Menschen, der
> auf einem Mac mit Xcode weiterbaut. Dieses Dokument ist die einzige Quelle der
> Wahrheit für die iOS-Push-Fertigstellung. Die Swift-/Dart-Dateien existieren
> bereits (auf Linux textuell erstellt, **nie kompiliert**). Was fehlt, ist die
> reine Xcode-Verdrahtung (Target anlegen, Capabilities, Signing) — das geht
> **nur** auf dem Mac.
---
## 1. Architektur-Kurzfassung
```
Nextcloud (cloud.marianum-fulda.de)
│ push-v2: E2E-verschlüsselte Notification (mit Device-Public-Key verschlüsselt)
▼
MarianumConnect Push-Proxy (connect.marianum-fulda.de/push-proxy/notifications)
│ verifiziert Signatur, leitet per FCM HTTP v1 weiter
▼
FCM → APNs → iOS-Gerät
│ alert-Push mit `mutable-content: 1`, `subject` + `signature` top-level im Payload
▼
NotificationServiceExtension (DIESES Target)
│ liest Device-Private-Key + Server-Public-Key aus dem geteilten Keychain
│ verifiziert Signatur → entschlüsselt subject → setzt Titel/Body/Category
▼
iOS zeigt die fertige Notification
```
- **Verschlüsselung:** OAEP-SHA1 (NC-32-Default) mit Fallback PKCS#1 v1.5.
- **Signatur:** `SHA512withRSA` über die **base64-dekodierten (verschlüsselten)**
subject-Bytes, geprüft mit dem per-User-Server-Public-Key aus der Registrierung.
- **Dart-Referenz:** `lib/push/` — insb. `push_decryptor.dart` (Krypto-Spiegel),
`push_secure_storage.dart` (Keychain-Optionen), `push_keypair.dart` (Keyformat),
`push_registration_store.dart` (was group-scoped gespeichert wird),
`push_registration.dart` (`_persistNativeAuthContext`).
- **Backend-Referenz:** MarianumConnect-Service `marMobileApi`, Endpunkte
`push-proxy/notifications` (NC→Proxy) und `PUT/DELETE me/push-device`.
- **Talk-Actions (Antworten / Als gelesen markieren):** werden **nativ** im
`AppDelegate` per `URLSession` an die Talk-OCS-API geschickt, weil bei einer
Notification-Action nicht garantiert ist, dass die Flutter-Engine läuft.
---
## 2. Dateiinventar
| Datei | Status | Anmerkung |
|-------|--------|-----------|
| `ios/NotificationServiceExtension/NotificationService.swift` | **existiert** | NSE-Hauptklasse (entschlüsseln + Content setzen) |
| `ios/NotificationServiceExtension/PEM.swift` | **existiert** | PEM/DER→`SecKey` (PKCS#8/SPKI-Stripping) |
| `ios/NotificationServiceExtension/Info.plist` | **existiert** | `NSExtensionPointIdentifier = com.apple.usernotifications.service` |
| `ios/NotificationServiceExtension/NotificationServiceExtension.entitlements` | **existiert** | App Group + keychain-access-groups |
| `ios/Runner/Runner.entitlements` | **geändert** | `keychain-access-groups` (widget-Group) ergänzt |
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
| `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
| **Xcode-Target „NotificationServiceExtension"** | **existiert** | programmatisch via `xcodeproj`-Gem angelegt (2026-07-07), gespiegelt an der Share-Extension |
| `ios/Runner.xcodeproj/project.pbxproj` | **geändert** | NSE-Target, Dependency + „Embed Foundation Extensions" ergänzt |
| `ios/Flutter/NotificationServiceExtension-{Debug,Release,Profile}.xcconfig` | **neu** | Base-Configs, inkludieren `Generated.xcconfig` (Flutter-Versionsvariablen) |
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
> Platzhalter-Dateien — **diese löschen und die vorhandenen Dateien zuordnen**
> (siehe 3.2), sonst überschreibst du die fertige Implementierung.
---
## 3. Xcode-Checkliste (auf dem Mac)
> **Stand 2026-07-07:** Abschnitte 3.1–3.2 (Target anlegen, Dateien zuordnen) sind
> bereits **programmatisch** erledigt (via `xcodeproj`-Gem). Der unsignierte Build
> aller Targets läuft durch (`flutter build ios --no-codesign`), die
> `NotificationServiceExtension.appex` wird korrekt in `Runner.app/PlugIns/`
> eingebettet. **Offen bleiben nur noch Signing/Capabilities (3.3–3.4, 3.6)** —
> die brauchen den Apple-Developer-Account und einen signierten Build/Archive.
> Die 3.1/3.2-Anleitung unten bleibt als Referenz stehen (falls das Target mal neu
> aufgesetzt werden muss).
### 3.1 Target anlegen
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
2. **File → New → Target… → iOS → Notification Service Extension**.
3. **Product Name exakt: `NotificationServiceExtension`** (der
`NSExtensionPrincipalClass` in der Info.plist ist
`$(PRODUCT_MODULE_NAME).NotificationService` — bei abweichendem Namen zerbricht
das). Language: Swift. „Embed in Application": Runner.
4. Beim Dialog „Activate scheme?" → Activate.
5. **Bundle Identifier:** `eu.mhsl.marianum.mobile.client.NotificationServiceExtension`
(Muster wie bei den bestehenden Extensions).
6. **Deployment Target = 15.0** (identisch zum Runner; im `post_install` des
Podfiles wird ohnehin alles auf 15.0 gezwungen).
### 3.2 Generierte Dateien durch die vorhandenen ersetzen
- Xcode legt eine eigene `NotificationService.swift` und `Info.plist` im
Target-Ordner an. **Beide aus dem Projekt entfernen** („Move to Trash" für die
frisch generierten) und stattdessen die bereits vorhandenen Dateien hinzufügen:
- `NotificationService.swift`, `PEM.swift` → **Target Membership: nur NSE**.
- `Info.plist` → in den **Build Settings** des NSE-Targets als
`INFOPLIST_FILE = NotificationServiceExtension/Info.plist` setzen (bzw. die
vorhandene Datei als Info.plist des Targets referenzieren).
- `NotificationServiceExtension.entitlements` → in Build Settings
`CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements`.
### 3.3 Capabilities (in **beiden** Targets: Runner **und** NSE)
- **App Groups:** `group.eu.mhsl.marianum.mobile.client.widget` aktivieren.
(Beim Runner ist die Group bereits vorhanden; beim NSE neu hinzufügen.)
- **Keychain Sharing:** Keychain-Group `group.eu.mhsl.marianum.mobile.client.widget`
hinzufügen. Die Entitlement-Dateien enthalten das schon als
`keychain-access-groups`; über die Capabilities-UI stellst du sicher, dass das
Provisioning-Profil es abdeckt.
- Prüfen, dass die Capabilities-UI **keine** doppelten/abweichenden Einträge
erzeugt hat (Xcode schreibt gern in die `.entitlements`; danach Abschnitt 4
gegenchecken).
### 3.4 Signing
- Beide Targets: **Team** = dasselbe wie Runner. Automatic Signing an, oder die
passenden Profile wählen. Der App-Identifier-Prefix (Team-ID) muss identisch
sein, sonst greift die Keychain-Group nicht.
### 3.5 Pods / Podfile
- Die NSE ist **komplett selbst-enthalten** (`Foundation`, `Security`,
`UserNotifications` — alles System-Frameworks). **Kein** Pod-Target nötig.
- Prüfen, ob irgendein Flutter-Plugin explizit ein NSE-Target im Podfile verlangt
(aktuell **nein** — `firebase_messaging` braucht keins). Falls doch mal nötig:
im Podfile analog zur `Share Extension` einen `target
'NotificationServiceExtension' do inherit! :search_paths end`-Block ergänzen,
danach `pod install`.
- Nach dem Anlegen einmal `cd ios && pod install` laufen lassen (aktualisiert die
Workspace-Referenzen).
### 3.6 aps-environment (Production!)
- `ios/Runner/Runner.entitlements` hat aktuell `aps-environment = development`.
**Für TestFlight/Release muss `production` aktiv sein.** Das wurde hier bewusst
**nicht** hart umgestellt, um den Debug-Flow nicht zu brechen.
- Empfehlung: über die **Capabilities → Push Notifications** und die
Build-Konfiguration steuern (Xcode setzt beim Archive automatisch `production`,
wenn das Profil ein Distribution-Profil ist). Beim Archive-Export kontrollieren,
dass in der finalen `.ipa` `aps-environment = production` steht (siehe 5.2).
---
## 4. Ermittelte Keychain-Details (verbindlich)
Die Dart-Seite schreibt mit
`IOSOptions(groupId: 'MY55VF3KPG.eu.mhsl.marianum.mobile.client.push', accessibility: first_unlock)`.
> **Wichtig (Stand 2026-07-07):** Als Keychain-Access-Group wird **nicht** mehr die
> App-Group (`group.*`) genutzt, sondern eine **team-prefixed** Group
> (`$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push`). Grund: Die
> Xcode-verwalteten Provisioning-Profile gewähren als `keychain-access-groups`
> nur `<TeamID>.*` — eine `group.*`-App-Group fällt da **nicht** drunter, was
> Automatic Signing mit „doesn't match the entitlements file's value for the
> keychain-access-groups entitlement" abbricht. Runner und NSE teilen die Group,
> weil sie mit demselben Team (`MY55VF3KPG`) signieren. Der `MY55VF3KPG.`-Prefix
> ist der stabile AppIdentifierPrefix und in Dart/Swift hart hinterlegt.
Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
`pubspec.lock`) ergibt sich die exakte Ablage im Keychain:
| Keychain-Attribut | Wert |
|-------------------|------|
| `kSecClass` | `kSecClassGenericPassword` |
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
| `kSecAttrAccessGroup` | `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push` (team-prefixed) |
| `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) |
| Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) |
**Deshalb** fragt die Swift-Seite (`keychainString(...)` in NSE **und**
AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = <key>` +
`kSecAttrAccessGroup` + `kSecMatchLimit=One` + `kSecReturnData=true`, **ohne**
`kSecAttrService`. (Ein gesetztes `kSecAttrService` würde nicht matchen.)
### Konkrete Einträge (Account-Namen)
| Account (`kSecAttrAccount`) | Inhalt | Geschrieben von | Genutzt von |
|-----------------------------|--------|-----------------|-------------|
| `push_device_private_key_pem` | Device-**Private**-Key, PEM | `push_keypair.dart` | **NSE** (entschlüsseln) |
| `push_device_public_key_pem` | Device-Public-Key, SPKI-PEM | `push_keypair.dart` | — (nur Registrierung) |
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
| `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) |
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
> `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
> App-Password lag schon immer group-scoped.
### Schlüsselformate (kritisch für das PEM-Parsing)
`crypton` (2.2.1) erzeugt die Keys:
- **Private Key:** Label `-----BEGIN RSA PRIVATE KEY-----`, der DER-Body ist aber
**PKCS#8** `PrivateKeyInfo` (SEQ{ INTEGER version, SEQ algId, OCTET STRING
pkcs1 }) — **nicht** rohes PKCS#1! `SecKeyCreateWithData` will bei RSA aber
**rohes PKCS#1**. `PEM.pkcs1PrivateKey(fromPkcs8:)` strippt den PKCS#8-Wrapper
(extrahiert den OCTET-STRING-Inhalt).
- **Public Key** (Device **und** der von NC gelieferte Server-Key): Label
`-----BEGIN PUBLIC KEY-----`, DER = **SPKI** `SubjectPublicKeyInfo`.
`PEM.pkcs1PublicKey(fromSpki:)` extrahiert den BIT-STRING-Inhalt und wirft das
führende `0x00`-Byte (unused bits) weg → PKCS#1 `RSAPublicKey`.
---
## 5. Verifikation auf dem Mac
### 5.1 Build
- `flutter clean && flutter pub get`
- `cd ios && pod install`
- In Xcode: **beide** Targets bauen. NSE-Target-Scheme separat bauen, um Swift-
Fehler früh zu sehen.
- Auf echtem Gerät installieren (Push funktioniert **nicht** im Simulator).
### 5.2 aps-environment im Archive prüfen
- Product → Archive → Distribute (oder `.ipa` exportieren) → `.ipa` entpacken →
`codesign -d --entitlements :- Payload/Runner.app` → muss
`aps-environment = production` zeigen.
### 5.3 Testbenachrichtigung (schneller Smoke-Test, ohne NC-Krypto)
- In den App-Einstellungen → Benachrichtigungen → **Testbenachrichtigung**
(Backend `POST me/push-device/test`). Erwartung: Alert „Testbenachrichtigung /
Push-Benachrichtigungen funktionieren! 🎉". Testet Registrierung + FCM +
Rendering — die NSE ist hier **nicht** beteiligt (Connect-Push, kein
`mutable-content`).
### 5.4 Echte Talk-Nachricht (End-to-End inkl. NSE)
- Gerät sperren. Von einem Zweitaccount eine Talk-Nachricht schicken.
- **Erwartung:** Banner mit **echtem Absender + Nachrichtentext** (nicht der
Platzhalter „Neue Benachrichtigung / Tippen zum Öffnen"). Long-press →
Actions „Antworten" + „Als gelesen markieren".
- „Antworten" → Text senden → Nachricht erscheint im Chat (nativ via URLSession),
Chat wird als gelesen markiert.
- Alternativ auf der NC-Instanz: `occ notification:test-push --talk <user> -v`.
### 5.5 Woran erkenne ich, ob die NSE lief?
- **NSE lief & OK:** echter Absender/Text im Banner.
- **NSE lief nicht / Fehler:** Platzhalter-Text bleibt stehen. Das ist der
bewusste Fallback (nie leer, nie Crash).
- **Logs:** Über die macOS-**Console.app** das Gerät wählen und nach `[NSE]` bzw.
`[Talk action]` filtern (`NSLog`-Ausgaben). Oder in Xcode: Debug → Attach to
Process → `NotificationServiceExtension`.
---
## 6. Bekannte Fehlerbilder + Fixes
| Symptom | Wahrscheinliche Ursache | Fix |
|---------|-------------------------|-----|
| NSE greift gar nicht (immer Platzhalter, auch bei Talk) | `mutable-content` fehlt / Signing des NSE-Targets kaputt / Bundle-ID falsch | Backend-`FcmMessageFactory` setzt `mutable-content:1` (prüfen). NSE-Target-Signing + Provisioning prüfen. Bundle-ID = `…client.NotificationServiceExtension`. |
| Banner zeigt Platzhalter trotz Talk | PEM-Parsing/Padding schlägt fehl | Console.app nach `[NSE] SecKeyCreateWithData failed` / `could not decrypt`. Prüfen, dass Private-Key wirklich als PKCS#8-in-„RSA PRIVATE KEY" ankommt (crypton). OAEP↔PKCS1-Fallback ist schon drin. |
| `[NSE] no device private key in keychain` | Keychain-Group greift nicht | In **beiden** Targets Keychain Sharing + App Group aktiv? Team-ID identisch? Access-Group-String exakt `group.eu.mhsl.marianum.mobile.client.widget`? |
| Signatur schlägt immer fehl | Falscher/kein Server-Public-Key | `push_server_public_key_pem` vorhanden? (wird bei `register()` gesetzt). Notfalls: ohne Server-Key überspringt die NSE die Prüfung — bleibt sie hängen, ist der Key da aber falsch geparst. |
| Reply-Action tut nichts | Credentials fehlen / Delegate-Konflikt (s.u.) | Console.app nach `[Talk action]`. `nextcloud_username`/`_app_password`/`_base_url` im Keychain? App einmal neu einloggen (schreibt sie via `register()`). |
| Reply wird **doppelt** gesendet | AppDelegate **und** ein Plugin behandeln dieselbe Action | Der AppDelegate returnt bei TALK_REPLY/TALK_MARK_READ **ohne** `super`-Aufruf, das Plugin sieht die Action also nicht. Falls doch doppelt: prüfen, ob `flutter_local_notifications` auf iOS separat als Delegate registriert ist. |
### ⚠️ Delegate-Ownership (wichtigster Verifikationspunkt)
`FlutterAppDelegate` konformiert (über `FlutterAppLifeCycleProvider`) zu
`UNUserNotificationCenterDelegate` und **leitet** `userNotificationCenter(...)` an
die registrierten Plugins weiter (firebase_messaging,
flutter_local_notifications). Der `AppDelegate` setzt sich in
`didFinishLaunching` als `UNUserNotificationCenter.current().delegate = self`,
**überschreibt** die beiden Delegate-Methoden und ruft für alles **außer** den
zwei Talk-Actions `super` auf — dadurch bleibt das Plugin-Forwarding intakt. Das
ist der fragilste Teil und **muss auf dem Gerät verifiziert werden**:
- Normale Push-Taps müssen weiterhin ins richtige Chat/Ziel navigieren
(läuft über `super` → Plugins).
- Foreground-Darstellung von FCM darf nicht kaputtgehen (`willPresent` wird hier
bewusst **nicht** überschrieben, läuft komplett über `super`/Plugins).
- Sollte ein Plugin den Delegate **nach** `didFinishLaunching` erneut übernehmen
(und damit unsere Talk-Actions abfangen), das Setzen von
`UNUserNotificationCenter.current().delegate = self` an einen späteren Punkt
verschieben (z. B. nach dem ersten Dart-Frame). Ohne Gerät nicht abschließend
testbar.
> **Compile-Hinweis:** `override` an den beiden `userNotificationCenter`-Methoden
> ist korrekt, weil die Methoden über das von `FlutterAppDelegate` adoptierte
> Protokoll `FlutterAppLifeCycleProvider : UNUserNotificationCenterDelegate`
> sichtbar sind (verifiziert im Flutter-3.44-Engine-Header). `super.userNotification…`
> ist daher aufrufbar.
---
## 7. Offene TODOs / bewusste Einschränkungen
1. **Delete-Handling auf iOS (best effort).** Delete-Pushes kommen als silent
`background`-Pushes (`content-available`, **kein** `mutable-content`) → die NSE
läuft dafür **nicht**. Das Wegräumen erledigt der Flutter-Background-Handler
(`PushMessageHandler`) bzw. ein Sweep beim App-Öffnen. Erreicht doch mal ein
Delete-Subject die NSE, **kann** sie die Notification **nicht** unterdrücken
(eine NSE muss immer *irgendeinen* Content liefern) — sie lässt den Platzhalter
stehen und loggt. Kein Fix möglich, dokumentierte iOS-Limitierung.
2. **Rich-Detail via OCS (spätere Erweiterung).** v1 lädt **nichts** nach. Optional
könnte die NSE bei Talk `GET …/ocs/v2.php/apps/notifications/api/v2/notifications/{nid}`
mit dem App-Password abrufen, um Avatar/Rich-Subject anzuzeigen (Timeout < 25 s,
innerhalb des NSE-Budgets). Nicht implementiert.
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
vor dem Release erledigen und im Archive gegenchecken (5.2).
4. **Keychain-Access-Group-Schreibweise (gelöst 2026-07-07).** Die Entitlements
listen `$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push` als
`keychain-access-groups` (team-prefixed, **keine** App-Group). Damit greift das
`<TeamID>.*` der Xcode-Profile und Automatic Signing läuft ohne Portal-Änderung
durch (verifiziert: `flutter build ios --release` signiert Runner **und** NSE
mit `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push`). Der frühere App-Group-
Ansatz (`group.*`) scheiterte an genau diesem Profil-Matching. Falls der
Keychain-Zugriff zur Laufzeit doch scheitert (Status `-34018` /
`errSecMissingEntitlement`), prüfen, dass Dart (`push_secure_storage.dart`) und
Swift (`AppDelegate.swift`, `NotificationService.swift`) **exakt denselben**
vollqualifizierten Group-String verwenden.
-3
View File
@@ -46,8 +46,5 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target) flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end
end end
end end
+269 -4
View File
@@ -1,46 +1,311 @@
PODS: PODS:
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- emoji_picker_flutter (0.0.1):
- Flutter
- eraser (0.0.1): - eraser (0.0.1):
- Flutter - Flutter
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/CoreOnly (12.12.0):
- FirebaseCore (~> 12.12.0)
- Firebase/Messaging (12.12.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.12.0)
- firebase_core (4.7.0):
- Firebase/CoreOnly (= 12.12.0)
- Flutter
- firebase_messaging (16.2.0):
- Firebase/Messaging (= 12.12.0)
- firebase_core
- Flutter
- FirebaseCore (12.12.1):
- FirebaseCoreInternal (~> 12.12.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreInternal (12.12.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInstallations (12.12.0):
- FirebaseCore (~> 12.12.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (12.12.0):
- FirebaseCore (~> 12.12.0)
- FirebaseInstallations (~> 12.12.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Reachability (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_app_badge (2.0.0): - flutter_app_badge (2.0.0):
- Flutter - Flutter
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/Reachability (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- home_widget (0.0.1):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- in_app_review (2.0.0):
- Flutter
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- open_filex (0.0.2): - open_filex (0.0.2):
- Flutter - Flutter
- package_info_plus (0.4.5):
- Flutter
- PhoneNumberKit (3.7.11): - PhoneNumberKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore (= 3.7.11) - PhoneNumberKit/PhoneNumberKitCore (= 3.7.11)
- PhoneNumberKit/UIKit (= 3.7.11) - PhoneNumberKit/UIKit (= 3.7.11)
- PhoneNumberKit/PhoneNumberKitCore (3.7.11) - PhoneNumberKit/PhoneNumberKitCore (3.7.11)
- PhoneNumberKit/UIKit (3.7.11): - PhoneNumberKit/UIKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore - PhoneNumberKit/PhoneNumberKitCore
- PromisesObjC (2.4.0)
- receive_sharing_intent (1.8.1):
- Flutter
- screen_brightness_ios (2.1.3):
- Flutter
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5)
- syncfusion_flutter_pdfviewer (0.0.1):
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- wakelock_plus (0.0.1):
- Flutter
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`)
- eraser (from `.symlinks/plugins/eraser/ios`) - eraser (from `.symlinks/plugins/eraser/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/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`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`) - open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- PhoneNumberKit (~> 3.7.6) - PhoneNumberKit (~> 3.7.6)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PhoneNumberKit - PhoneNumberKit
- PromisesObjC
- SDWebImage
- SwiftyGif
EXTERNAL SOURCES: EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
emoji_picker_flutter:
:path: ".symlinks/plugins/emoji_picker_flutter/ios"
eraser: eraser:
:path: ".symlinks/plugins/eraser/ios" :path: ".symlinks/plugins/eraser/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_app_badge: flutter_app_badge:
:path: ".symlinks/plugins/flutter_app_badge/ios" :path: ".symlinks/plugins/flutter_app_badge/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
open_filex: open_filex:
:path: ".symlinks/plugins/open_filex/ios" :path: ".symlinks/plugins/open_filex/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
syncfusion_flutter_pdfviewer:
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc
eraser: 83a4b06985f3702aa3d8dec816f9693266012937 eraser: 83a4b06985f3702aa3d8dec816f9693266012937
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Firebase: aa154fee4e9b8eac17aa42344988865b3e857d33
firebase_core: 9156a152117c843440b0b990c785aa0259bc5447
firebase_messaging: 0d962ab44ff24ed36deb8fa2ee043c4671858269
FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284
FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9
FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e
FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
PhoneNumberKit: ced55861269312a5e3bc2ef82a58d6255b1c976a
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
screen_brightness_ios: 212d950bb99c915eee971c884f4a6c87c92cd13d
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6 PODFILE CHECKSUM: 424a9b4c0fe81d8ebeaa9cb0dfedb60a68b19a0d
COCOAPODS: 1.17.0 COCOAPODS: 1.16.2
+19 -201
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 70; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -12,18 +12,13 @@
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 */; };
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F5B421EAF56B77B775E58E92 /* Foundation.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -41,13 +36,6 @@
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 */
@@ -59,7 +47,6 @@
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;
@@ -77,24 +64,17 @@
/* 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>"; };
@@ -113,12 +93,9 @@
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>"; };
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 */
@@ -178,7 +155,6 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */, B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -190,14 +166,6 @@
); );
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 */
@@ -214,39 +182,18 @@
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 = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */, 9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -257,9 +204,6 @@
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>";
@@ -274,7 +218,6 @@
97C146EF1CF9000F007C117D /* Products */, 97C146EF1CF9000F007C117D /* Products */,
345F4BD4143471FDA71626DE /* Pods */, 345F4BD4143471FDA71626DE /* Pods */,
731388A08E3B330B216381D0 /* Frameworks */, 731388A08E3B330B216381D0 /* Frameworks */,
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -284,7 +227,6 @@
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>";
@@ -344,18 +286,15 @@
3321F8102FB1C00C0011C712 /* Embed Foundation Extensions */, 3321F8102FB1C00C0011C712 /* Embed Foundation Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
174B54D80220E5F588BD9737 /* [CP] Embed Pods Frameworks */, 174B54D80220E5F588BD9737 /* [CP] Embed Pods Frameworks */,
859FAB4E05FAC31B7B1A62D7 /* [CP] Copy Pods Resources */,
); );
buildRules = ( buildRules = (
); );
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";
@@ -380,23 +319,6 @@
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 */
@@ -429,9 +351,6 @@
Base, Base,
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
@@ -439,19 +358,11 @@
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;
@@ -514,6 +425,23 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
}; };
859FAB4E05FAC31B7B1A62D7 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
@@ -600,15 +528,6 @@
); );
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 */
@@ -617,12 +536,6 @@
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 */;
@@ -1024,29 +937,6 @@
}; };
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 */;
@@ -1169,54 +1059,6 @@
}; };
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 */
@@ -1260,31 +1102,7 @@
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 */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = 97C146E61CF9000F007C117D /* Project object */; rootObject = 97C146E61CF9000F007C117D /* Project object */;
} }
@@ -1,176 +0,0 @@
{
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a",
"version" : "11.3.2"
}
},
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "27eaab3918e0bf78711cf1abf240577176326432",
"version" : "12.19.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.7.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
"version" : "12.19.2"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974",
"version" : "10.1.1"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.3"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc",
"version" : "5.3.1"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}
@@ -5,24 +5,6 @@
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -5,44 +5,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git", "location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : { "state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", "revision" : "bfc0b6f81adc06ce5121eb23f628473638d67c5c",
"version" : "1.2024072200.0" "version" : "1.2022062300.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
"version" : "11.3.0"
}
},
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
} }
}, },
{ {
@@ -50,17 +14,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk", "location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : { "state" : {
"revision" : "27eaab3918e0bf78711cf1abf240577176326432", "revision" : "df2171b0c6afb9e9d4f7e07669d558c510b9f6be",
"version" : "12.19.0" "version" : "10.13.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.7.0"
} }
}, },
{ {
@@ -68,8 +23,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git", "location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : { "state" : {
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f", "revision" : "03b9beee1a61f62d32c521e172e192a1663a5e8b",
"version" : "12.19.2" "version" : "10.13.0"
} }
}, },
{ {
@@ -77,8 +32,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git", "location" : "https://github.com/google/GoogleDataTransport.git",
"state" : { "state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9", "revision" : "aae45a320fd0d11811820335b1eabc8753902a40",
"version" : "10.1.0" "version" : "9.2.5"
} }
}, },
{ {
@@ -86,8 +41,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git", "location" : "https://github.com/google/GoogleUtilities.git",
"state" : { "state" : {
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3", "revision" : "c38ce365d77b04a9a300c31061c5227589e5597b",
"version" : "8.1.3" "version" : "7.11.5"
} }
}, },
{ {
@@ -95,8 +50,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git", "location" : "https://github.com/google/grpc-binary.git",
"state" : { "state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", "revision" : "f1b366129d1125be7db83247e003fc333104b569",
"version" : "1.69.1" "version" : "1.50.2"
} }
}, },
{ {
@@ -104,17 +59,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" : "d415594121c9e8a4f9d79cecee0965cf35e74dbd",
"version" : "5.3.0" "version" : "3.1.1"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
} }
}, },
{ {
@@ -122,8 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git", "location" : "https://github.com/firebase/leveldb.git",
"state" : { "state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", "revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
"version" : "1.22.5" "version" : "1.22.2"
} }
}, },
{ {
@@ -131,8 +77,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git", "location" : "https://github.com/firebase/nanopb.git",
"state" : { "state" : {
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", "revision" : "819d0a2173aff699fb8c364b6fb906f7cdb1a692",
"version" : "2.30910.1" "version" : "2.30909.0"
} }
}, },
{ {
@@ -140,35 +86,17 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git", "location" : "https://github.com/google/promises.git",
"state" : { "state" : {
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", "revision" : "e70e889c0196c76d22759eb50d6a0270ca9f1d9e",
"version" : "2.4.1" "version" : "2.3.1"
} }
}, },
{ {
"identity" : "sdwebimage", "identity" : "swift-protobuf",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage", "location" : "https://github.com/apple/swift-protobuf.git",
"state" : { "state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", "revision" : "ce20dc083ee485524b802669890291c0d8090170",
"version" : "5.21.7" "version" : "1.22.1"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
} }
} }
], ],
+1 -202
View File
@@ -1,217 +1,16 @@
import Flutter import Flutter
import UIKit import UIKit
import UserNotifications
import workmanager_apple
@main @main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
// Kept in sync with lib/push/push_actions.dart (kTalkReplyActionId /
// kTalkMarkReadActionId) and lib/push/push_renderer.dart (iosTalkCategory).
private let talkCategoryId = "TALK_MESSAGE"
private let replyActionId = "TALK_REPLY"
private let markReadActionId = "TALK_MARK_READ"
// Shared (App Group) keychain — same group as the NSE and the Dart side.
private let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
private let usernameAccount = "nextcloud_username"
private let appPasswordAccount = "nextcloud_app_password"
private let baseUrlAccount = "nextcloud_base_url"
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
registerTalkCategory()
// BGAppRefresh for the home-screen widget. Must all happen before
// didFinishLaunching returns: with the UIScene lifecycle Flutter registers
// plugins only during scene connection, which is past BGTaskScheduler's
// registration deadline — registerLaunchHandlers() bridges that gap.
// The task identifier mirrors WidgetBackgroundTask.periodicTaskName; the
// plugin re-submits the refresh request itself after every run.
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
GeneratedPluginRegistrant.register(with: registry)
}
WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "eu.mhsl.marianum.widget.refresh",
earliestBeginInSeconds: 1800)
WorkmanagerPlugin.registerLaunchHandlers()
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
// forwards these callbacks to the plugins (firebase_messaging,
// flutter_local_notifications). We route Talk actions natively here — the
// Flutter engine is not guaranteed to run for a background action — and
// forward everything else to `super` so plugin behaviour is preserved.
UNUserNotificationCenter.current().delegate = self
return result
} }
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
} }
private func registerTalkCategory() {
let reply = UNTextInputNotificationAction(
identifier: replyActionId,
title: "Antworten",
options: [],
textInputButtonTitle: "Senden",
textInputPlaceholder: "Nachricht"
)
let markRead = UNNotificationAction(
identifier: markReadActionId,
title: "Als gelesen markieren",
options: []
)
let category = UNNotificationCategory(
identifier: talkCategoryId,
actions: [reply, markRead],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([category])
}
// MARK: - UNUserNotificationCenterDelegate
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let actionId = response.actionIdentifier
if actionId == replyActionId || actionId == markReadActionId {
// Handled natively; deliberately NOT forwarded to super so the plugins
// don't also process it (which would double-send the reply).
handleTalkAction(response, completion: completionHandler)
return
}
super.userNotificationCenter(
center, didReceive: response, withCompletionHandler: completionHandler)
}
// MARK: - Native Talk action handling
private func handleTalkAction(
_ response: UNNotificationResponse,
completion: @escaping () -> Void
) {
guard
let token = chatToken(from: response),
let credentials = loadCredentials()
else {
completion()
return
}
let group = DispatchGroup()
if response.actionIdentifier == replyActionId,
let text = (response as? UNTextInputNotificationResponse)?.userText
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
group.enter()
ocsPost(
credentials: credentials,
path: "apps/spreed/api/v1/chat/\(token)",
form: ["message": text]
) { group.leave() }
}
// Both reply and mark-read clear the unread marker on the chat.
group.enter()
ocsPost(
credentials: credentials,
path: "apps/spreed/api/v1/chat/\(token)/read",
form: nil
) { group.leave() }
group.notify(queue: .main) { completion() }
}
private func chatToken(from response: UNNotificationResponse) -> String? {
let userInfo = response.notification.request.content.userInfo
if let token = userInfo["chatToken"] as? String, !token.isEmpty {
return token
}
let thread = response.notification.request.content.threadIdentifier
if thread.hasPrefix("talk_") {
let token = String(thread.dropFirst("talk_".count))
return token.isEmpty ? nil : token
}
return nil
}
private struct Credentials {
let baseUrl: String
let authorization: String
}
private func loadCredentials() -> Credentials? {
guard
let username = keychainString(usernameAccount),
let appPassword = keychainString(appPasswordAccount),
let baseUrl = keychainString(baseUrlAccount),
let token = "\(username):\(appPassword)".data(using: .utf8)
else { return nil }
let authorization = "Basic \(token.base64EncodedString())"
let trimmed = baseUrl.hasSuffix("/") ? String(baseUrl.dropLast()) : baseUrl
return Credentials(baseUrl: trimmed, authorization: authorization)
}
private func ocsPost(
credentials: Credentials,
path: String,
form: [String: String]?,
completion: @escaping () -> Void
) {
guard let url = URL(string: "\(credentials.baseUrl)/ocs/v2.php/\(path)") else {
completion()
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(credentials.authorization, forHTTPHeaderField: "Authorization")
request.setValue("true", forHTTPHeaderField: "OCS-APIRequest")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let form = form {
request.setValue(
"application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = form
.map { "\($0.key)=\(formEncode($0.value))" }
.joined(separator: "&")
.data(using: .utf8)
}
URLSession.shared.dataTask(with: request) { _, response, error in
if let error = error {
NSLog("[Talk action] \(path) failed: \(error.localizedDescription)")
} else if let http = response as? HTTPURLResponse,
!(200..<300).contains(http.statusCode) {
NSLog("[Talk action] \(path) -> HTTP \(http.statusCode)")
}
completion()
}.resume()
}
private func formEncode(_ value: String) -> String {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
}
// MARK: - Keychain (shared App Group)
private func keychainString(_ account: String) -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
kSecAttrAccessGroup: keychainAccessGroup,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
} }
+95 -84
View File
@@ -1,94 +1,105 @@
<?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>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>eu.mhsl.marianum.widget.refresh</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Marianum Fulda</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>client</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
<key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>AppGroupId</key>
<false/> <string>$(CUSTOM_GROUP_ID)</string>
<key>UISceneConfigurations</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Marianum Fulda</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>client</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Um Haltestellen in deiner Nähe im RMV-Fahrplan zu finden, wird dein aktueller Standort benötigt.</string>
<key>NSAppTransportSecurity</key>
<dict> <dict>
<key>UIWindowSceneSessionRoleApplication</key> <key>NSExceptionDomains</key>
<array> <dict>
<key>muelleel.ddns.net</key>
<dict> <dict>
<key>UISceneClassName</key> <key>NSExceptionAllowsInsecureHTTPLoads</key>
<string>UIWindowScene</string> <true/>
<key>UISceneConfigurationName</key> <key>NSIncludesSubdomains</key>
<string>flutter</string> <true/>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict> </dict>
</array> </dict>
</dict> </dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict> </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>
-4
View File
@@ -9,9 +9,5 @@
<string>group.eu.mhsl.marianum.mobile.client.widget</string> <string>group.eu.mhsl.marianum.mobile.client.widget</string>
<string>group.eu.mhsl.marianum.mobile.client.share</string> <string>group.eu.mhsl.marianum.mobile.client.share</string>
</array> </array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
</array>
</dict> </dict>
</plist> </plist>
+4 -27
View File
@@ -13,44 +13,21 @@ class SceneDelegate: FlutterSceneDelegate {
options connectionOptions: UIScene.ConnectionOptions options connectionOptions: UIScene.ConnectionOptions
) { ) {
super.scene(scene, willConnectTo: session, options: connectionOptions) super.scene(scene, willConnectTo: session, options: connectionOptions)
var handledShare = false for context in connectionOptions.urlContexts {
for context in connectionOptions.urlContexts where isShareUrl(context.url) { _ = SwiftReceiveSharingIntentPlugin.instance.application(
_ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared, UIApplication.shared,
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url] didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
) )
handledShare = true
} }
if handledShare { clearSharedPayload() }
} }
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
let shares = URLContexts.filter { isShareUrl($0.url) } for context in URLContexts {
for context in shares { _ = SwiftReceiveSharingIntentPlugin.instance.application(
_ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared, UIApplication.shared,
open: context.url, open: context.url,
options: [:] options: [:]
) )
} }
if !shares.isEmpty { clearSharedPayload() }
let others = URLContexts.subtracting(shares)
if !others.isEmpty { super.scene(scene, openURLContexts: others) }
}
private func isShareUrl(_ url: URL) -> Bool {
ReceiveSharingIntentPlugin.instance.hasMatchingSchemePrefix(url: url)
}
// The plugin keeps the payload in memory once read but never removes it
// from the App Group defaults. The Share Extension opens the app twice per
// share and a scene reconnect replays the URL, so every later delivery
// would publish the finished share again.
private func clearSharedPayload() {
let groupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String
let defaults = UserDefaults(
suiteName: groupId ?? "group.\(Bundle.main.bundleIdentifier ?? "")")
defaults?.removeObject(forKey: "ShareKey")
defaults?.removeObject(forKey: "ShareMessageKey")
} }
} }
+10 -46
View File
@@ -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
// ReceiveSharingIntentPlugin auf der Host-App-Seite decodiert. // SwiftReceiveSharingIntentPlugin 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,54 +152,17 @@ final class ShareViewController: UIViewController {
} }
} }
// loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell. private func copyIntoAppGroup(src: URL) -> URL? {
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 root = container.appendingPathComponent("share_intent", isDirectory: true)
sweepStaleShares(in: root)
let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
} catch {
return nil
}
}()
// Shares, deren App-Flow nie abgeschlossen wurde (Prozess beendet o. ä.),
// räumt sonst niemand weg — der App-Group-Container wird nie geleert.
private func sweepStaleShares(in root: URL) {
let fm = FileManager.default
guard let entries = try? fm.contentsOfDirectory(
at: root, includingPropertiesForKeys: [.creationDateKey]) else { return }
let cutoff = Date().addingTimeInterval(-24 * 60 * 60)
for entry in entries {
let created = (try? entry.resourceValues(forKeys: [.creationDateKey]))?.creationDate
if let created, created < cutoff { try? fm.removeItem(at: entry) }
}
}
private func copyIntoAppGroup(src: URL) -> URL? {
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 let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
var dst = dir.appendingPathComponent(name) let dst = container.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 {
@@ -208,9 +171,10 @@ final class ShareViewController: UIViewController {
} }
private func writePng(image: UIImage) -> URL? { private func writePng(image: UIImage) -> URL? {
guard let data = image.pngData(), guard let container = FileManager.default
let dir = fileQueue.sync(execute: { shareDir }) else { return nil } .containerURL(forSecurityApplicationGroupIdentifier: appGroupId),
let dst = dir.appendingPathComponent("\(UUID().uuidString).png") let data = image.pngData() else { return nil }
let dst = container.appendingPathComponent("\(UUID().uuidString).png")
return (try? data.write(to: dst)) != nil ? dst : nil return (try? data.write(to: dst)) != nil ? dst : nil
} }
@@ -1,109 +0,0 @@
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,6 +1,59 @@
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
// Layout constants — must mirror WidgetRenderer.kt on Android, otherwise
// the platforms drift apart on the same widget size.
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
let MIN_HOUR_HEIGHT: CGFloat = 18
let MAX_HOUR_HEIGHT: CGFloat = 72
let MIN_BLOCK_HEIGHT: CGFloat = 16
let LESSON_GAP: CGFloat = 1.5
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
guard !periods.isEmpty else { return CGFloat(realMin) }
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
}
let first = periods.first!
if realMin < first.startMinutes {
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
}
let last = periods.last!
if realMin > last.endMinutes {
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
}
var prev = first
for i in 1..<periods.count {
let curr = periods[i]
if realMin > prev.endMinutes && realMin < curr.startMinutes {
let gap = curr.startMinutes - prev.endMinutes
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
if gap > 0 {
return CGFloat(prev.virtualEndMinutes) +
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
}
return CGFloat(curr.virtualStartMinutes)
}
prev = curr
}
return 0
}
// Below these tile heights the secondary stack collapses progressively:
// shorter than ROOM_MIN drops everything, between ROOM_MIN and
// TEACHER_SEPARATE_MIN keeps the room but drops the teacher.
let BLOCK_SHOW_ROOM_MIN: CGFloat = 13
let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 20
let MIN_SUBJECT_FONT: CGFloat = 9
let MAX_SUBJECT_FONT: CGFloat = 14
let MIN_SECONDARY_FONT: CGFloat = 7
func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat {
let t = max(0, min(1, (hourHeight - MIN_HOUR_HEIGHT) / (MAX_HOUR_HEIGHT - MIN_HOUR_HEIGHT)))
return MIN_SUBJECT_FONT + t * (MAX_SUBJECT_FONT - MIN_SUBJECT_FONT)
}
struct TimetableDayView: View { struct TimetableDayView: View {
let entry: TimetableEntry let entry: TimetableEntry
@Environment(\.widgetPalette) private var palette @Environment(\.widgetPalette) private var palette
@@ -20,7 +73,7 @@ struct TimetableDayView: View {
@ViewBuilder @ViewBuilder
private func content(data: WidgetTimetableData) -> some View { private func content(data: WidgetTimetableData) -> some View {
VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) { VStack(alignment: .leading, spacing: 3) {
header(data: data) header(data: data)
if data.isHoliday { if data.isHoliday {
emptyState(text: data.holidayName ?? "Ferien") emptyState(text: data.holidayName ?? "Ferien")
@@ -28,41 +81,37 @@ struct TimetableDayView: View {
emptyState(text: "Keine Stunden") emptyState(text: "Keine Stunden")
} else { } else {
GeometryReader { geo in GeometryReader { geo in
let periods = trimTrailingPeriods(data.periods, lessons: data.lessons) let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
HStack(alignment: .top, spacing: 0) { TimeGridView(
PeriodRuler( lessons: data.lessons,
periods: periods, periods: data.periods,
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods) hourHeight: max(
) MIN_HOUR_HEIGHT,
Rectangle() min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
.fill(palette.divider) ),
.frame(width: 1) showRoom: true,
TimeGridView( showTeacher: true,
lessons: data.lessons, showTimeLabels: true
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: 6) { HStack(spacing: 4) {
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date)) Text(dayLabel(for: data.anchorDate))
.font(.system(size: 14, weight: .bold)) .font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.65) .minimumScaleFactor(0.7)
.frame(maxWidth: .infinity, alignment: .leading) Spacer(minLength: 4)
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
.font(.system(size: 10)) .font(.system(size: 9))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.fixedSize() .minimumScaleFactor(0.7)
} }
.frame(height: TITLE_ROW_HEIGHT)
} }
private func emptyState(text: String) -> some View { private func emptyState(text: String) -> some View {
@@ -90,23 +139,54 @@ struct TimetableDayView: View {
} }
} }
/// Mirrors the in-app _PeriodLabel: period number centred, start time above, struct TimeGridView: View {
/// end time below, a cup in the breaks. let lessons: [WidgetLesson]
struct PeriodRuler: View {
let periods: [WidgetPeriod] let periods: [WidgetPeriod]
let hourHeight: CGFloat let hourHeight: CGFloat
let showRoom: Bool
let showTeacher: Bool
let showTimeLabels: Bool
/// Week-widget passes 3 for narrow columns; day-widget keeps 7.
var horizontalPadding: CGFloat = 7
@Environment(\.widgetPalette) private var palette @Environment(\.widgetPalette) private var palette
private var totalHeight: CGFloat { private var totalVirtualMinutes: Int {
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0 periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
} }
private var totalHeight: CGFloat {
CGFloat(totalVirtualMinutes) * hourHeight / 60.0
}
/// Below this per-hour height the two-line label collapses to a single
/// period number — time + number overlap otherwise.
private var compactLabels: Bool { hourHeight < 26 }
var body: some View { var body: some View {
// Every child has layout-y 0 (`.offset` only shifts the rendering), HStack(alignment: .top, spacing: 0) {
// so both the ZStack and its frame need `.top` — the default if showTimeLabels {
// `.center` would anchor the whole ruler at the column's midpoint. // 28pt matches the Week widget's time-label column so the
ZStack(alignment: .top) { // visual rhythm is identical across both widgets.
timeLabelsColumn
.frame(width: 28, alignment: .topTrailing)
Rectangle()
.fill(palette.divider)
.frame(width: 1)
}
ZStack(alignment: .top) {
gridLines
breakBlocks
ForEach(lessons.indices, id: \.self) { idx in
lessonBlock(lessons[idx])
}
}
.frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
}
}
private var timeLabelsColumn: some View {
ZStack(alignment: .topTrailing) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle() Rectangle()
.fill(palette.divider) .fill(palette.divider)
@@ -114,91 +194,39 @@ struct PeriodRuler: View {
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
} }
ForEach(periods, id: \.startMinutes) { period in ForEach(periods, id: \.startMinutes) { period in
cell(period) VStack(alignment: .trailing, spacing: -2) {
} if compactLabels {
ForEach(periodBreaks(periods), id: \.self) { pause in Text("\(period.name).")
let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0 .font(.system(size: 9, weight: .bold))
if height >= BREAK_ICON_MIN_HEIGHT { .foregroundStyle(palette.textPrimary)
Image(systemName: "cup.and.saucer") .lineLimit(1)
.font(.system(size: 8)) } else {
.foregroundStyle(palette.textSecondary) Text(formatHm(period.startMinutes))
.frame(width: RULER_WIDTH, height: height) .font(.system(size: 9))
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0) .foregroundStyle(palette.textPrimary)
} .lineLimit(1)
} Text("\(period.name).")
} .font(.system(size: 7, weight: .bold))
.frame(width: RULER_WIDTH, height: totalHeight, alignment: .top) .foregroundStyle(palette.textSecondary)
} .lineLimit(1)
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)
} }
.padding(.trailing, 4)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
} }
.frame(width: geo.size.width, height: totalHeight, alignment: .top)
} }
.frame(height: totalHeight) .frame(height: totalHeight, alignment: .topTrailing)
} }
private var gridLines: some View { private var gridLines: some View {
// `.top` on both the ZStack and the frame — see PeriodRuler. // `.frame(height: totalHeight, alignment: .top)` — without the
// explicit `.top`, SwiftUI's frame modifier defaults to `.center`.
// Every Rectangle here has layout-y=0 (the `.offset()`s shift the
// rendered position only, not layout), so the inner ZStack's
// natural height is just 1pt; a 1pt ZStack centred in a 300pt
// frame anchors at the middle, and every line then renders
// relative to that midpoint — the grid visually starts halfway
// down the column and the later periods clip past the bottom.
ZStack(alignment: .top) { ZStack(alignment: .top) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle() Rectangle()
@@ -211,200 +239,170 @@ struct TimeGridView: View {
} }
private var breakBlocks: some View { private var breakBlocks: some View {
// Same `.center`-vs-`.top` bug as gridLines — see comment above.
ZStack(alignment: .top) { ZStack(alignment: .top) {
ForEach(periodBreaks(periods), id: \.self) { pause in ForEach(0..<max(0, periods.count - 1), id: \.self) { i in
let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0 let curr = periods[i]
ZStack { let next = periods[i + 1]
palette.breakBlock let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes
if height >= BREAK_ICON_MIN_HEIGHT { if virtualGap > 0 {
Image(systemName: "fork.knife") Rectangle()
.font(.system(size: 9)) .fill(palette.breakBlock)
.foregroundStyle(palette.accent) .frame(height: CGFloat(virtualGap) * hourHeight / 60.0)
} .padding(.horizontal, 1)
.offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0)
} }
.frame(height: height)
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
} }
} }
.frame(height: totalHeight, alignment: .top) .frame(height: totalHeight, alignment: .top)
} }
/// Mirrors the in-app holiday region: icon and "Schulfrei" on top, the private func formatHm(_ minutes: Int) -> String {
/// holiday name rotated along the column. String(format: "%02d:%02d", minutes / 60, minutes % 60)
private func holidayColumn(_ holiday: WidgetDayInfo) -> some View {
let nameLength = max(40, totalHeight - 2 * HOLIDAY_NAME_INSET)
return ZStack {
palette.holidayTint
VStack(spacing: 2) {
Image(systemName: "calendar")
.font(.system(size: 12))
Text("Schulfrei")
.font(.system(size: 9))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.padding(.top, 8)
.padding(.horizontal, 2)
.frame(maxHeight: .infinity, alignment: .top)
// The outer frame carries the rotated footprint: rotationEffect
// alone leaves the layout box at its unrotated size.
Text(holiday.holidayName ?? "")
.font(.system(size: 11, weight: .bold))
.kerning(1)
.lineLimit(1)
.minimumScaleFactor(0.6)
.frame(width: nameLength, height: 14)
.rotationEffect(.degrees(90))
.frame(width: 14, height: nameLength)
}
.foregroundStyle(palette.textPrimary)
.frame(height: totalHeight)
} }
@ViewBuilder @ViewBuilder
private func lessonBlock( private func lessonBlock(_ lesson: WidgetLesson) -> some View {
_ lesson: WidgetLesson, let cal = Calendar.current
metrics: TileMetrics, let comps = cal.dateComponents([.hour, .minute], from: lesson.start)
innerWidth: CGFloat let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
) -> some View {
let startMinutes = minutesOfDay(lesson.start)
let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60)) let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60))
let virtualStart = realMinutesToVirtual(startMinutes, periods: periods) let virtualStart = realMinutesToVirtual(startMinutes, periods: periods)
let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods) let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods)
if virtualEnd > virtualStart { if virtualEnd > virtualStart {
// Half the gap above + half below so the grid line under the
// tile stays visible.
let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2 let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2
let height = max( let height = max(
MIN_BLOCK_HEIGHT, MIN_BLOCK_HEIGHT,
(virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP (virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP
) )
LessonTile( let subjectSize = subjectFont(forHourHeight: hourHeight)
lesson: lesson, let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2)
height: height, let room = lesson.room
metrics: metrics, let teacher = lesson.teacher ?? lesson.originalTeacher
layout: TileTextLayout( let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false)
lesson: lesson, HStack(alignment: .top, spacing: 4) {
innerHeight: height - 2 * TILE_PADDING_V, Text(subjectLabel(lesson))
innerWidth: innerWidth, .font(.system(size: subjectSize, weight: .semibold))
metrics: metrics .foregroundStyle(.white)
) .lineLimit(hasSecondary ? 1 : 2)
) .minimumScaleFactor(0.5)
if hasSecondary {
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: -1) {
if showRoom && height >= BLOCK_SHOW_ROOM_MIN {
if let room, !room.isEmpty {
Text(room)
.font(.system(size: secondarySize))
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
.minimumScaleFactor(0.5)
}
if showTeacher,
height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN,
let teacher,
!teacher.isEmpty {
Text(teacher)
.font(.system(size: secondarySize))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
.minimumScaleFactor(0.5)
}
}
}
}
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .topLeading)
.frame(height: height, alignment: .topLeading)
.background(blockColor(lesson))
.cornerRadius(6)
.overlay(alignment: .bottomLeading) {
// Separate fixed-size badge so the +N hint stays readable
// when the subject autoshrinks on narrow tiles.
if let count = lesson.siblingCount, count > 0 {
Text("+\(count)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
.padding(.leading, horizontalPadding)
.padding(.bottom, 2)
}
}
.overlay {
// CrossPainter parity: clip cross to the rounded shape so
// the diagonals don't bleed past the corners.
if lesson.status == .cancelled {
ZStack {
RoundedRectangle(cornerRadius: 6)
.stroke(Color.red.opacity(0.78), lineWidth: 1.5)
GeometryReader { geo in
Path { p in
p.move(to: .zero)
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
p.move(to: CGPoint(x: geo.size.width, y: 0))
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
}
.stroke(Color.red.opacity(0.78), lineWidth: 3)
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
.padding(.horizontal, showRoom ? 2 : 1)
.offset(y: top) .offset(y: top)
} }
} }
}
/// Mirrors the in-app AppointmentTile: title on top, room and teacher private func subjectLabel(_ lesson: WidgetLesson) -> String {
/// stacked below, each line shrinking on its own to fit the tile width. !lesson.subjectShort.isEmpty
private struct LessonTile: View { ? lesson.subjectShort
let lesson: WidgetLesson : (lesson.subjectLong ?? "—")
let height: CGFloat
let metrics: TileMetrics
let layout: TileTextLayout
private static let cancelledRed =
Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255)
var body: some View {
let shape = RoundedRectangle(cornerRadius: TILE_CORNER_RADIUS)
VStack(alignment: .leading, spacing: 0) {
titleRow
ForEach(Array(layout.body.enumerated()), id: \.offset) { index, line in
Text(line)
.font(.system(size: metrics.bodyFont))
.lineLimit(1)
.minimumScaleFactor(MIN_BODY_LINE_FONT / metrics.bodyFont)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: metrics.bodyBox)
.padding(.trailing, badgeInset(line: index + 1))
}
}
.foregroundStyle(.white)
.padding(.horizontal, TILE_PADDING_H)
.padding(.vertical, TILE_PADDING_V)
.frame(maxWidth: .infinity, alignment: .topLeading)
.frame(height: height, alignment: .topLeading)
.background(lesson.tileColor)
.clipShape(shape)
.overlay(alignment: .bottomTrailing) {
if let count = lesson.siblingCount, count > 0 {
Text("+\(count)")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.padding(.trailing, TILE_PADDING_H)
.padding(.bottom, TILE_PADDING_V)
}
}
.overlay {
if lesson.status == .cancelled {
ZStack {
shape.strokeBorder(LessonTile.cancelledRed, lineWidth: 2)
GeometryReader { geo in
Path { p in
p.move(to: .zero)
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
p.move(to: CGPoint(x: geo.size.width, y: 0))
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
}
.stroke(LessonTile.cancelledRed, lineWidth: 2)
}
}
.clipShape(shape)
}
}
.padding(.horizontal, TILE_MARGIN_H)
} }
private var titleRow: some View { /// Mirrors lesson_color.dart + custom_event_colors.dart so the widget
HStack(alignment: .center, spacing: 3) { /// matches the in-app calendar exactly.
Text(layout.title) private func blockColor(_ lesson: WidgetLesson) -> Color {
.font(.system(size: layout.titleFont, weight: .medium)) if lesson.status == .event, let custom = lesson.customColor {
.lineLimit(layout.titleLines) switch custom {
.minimumScaleFactor(min(1, MIN_TITLE_FONT / layout.titleFont)) case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
.frame(maxWidth: .infinity, alignment: .leading) case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
if let inline = layout.inline { case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0)
Text(inline) case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0)
.font(.system(size: metrics.bodyFont)) default: break
.lineLimit(1)
.fixedSize()
} }
} }
.frame(height: layout.titleBox) switch lesson.status {
.padding(.trailing, badgeInset(line: 0)) // `.ongoing` deliberately collapses into the regular red — the
} // widget timeline only ticks every ~30min, so highlighting the
// "current" lesson would be stale most of the day and misleads
private func badgeInset(line: Int) -> CGFloat { // more than it helps.
layout.badgeLine == line ? BADGE_WIDTH : 0 case .regular, .past, .ongoing:
return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
case .cancelled: return .black
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
}
} }
} }
/// Fixed-locale formatters cached once — DateFormatter setup is the /// Period boundaries deduped: adjacent periods share a line, periods on
/// expensive part and the multi-entry timelines render up to ~8 entries per /// either side of a break get their own (bracketing the break block).
/// reload. Only touched from WidgetKit's archival rendering, so the shared func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
/// instances are safe. var seen = Set<Int>()
enum WidgetDateFormatters { var result: [Int] = []
static let shortDate = make("dd.MM.") for p in periods {
static let weekdayShort = make("EE") for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
static let weekdayDate = make("EEEE · dd.MM.") if seen.insert(v).inserted { result.append(v) }
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()
} }
/// `now` is the timeline entry's date, not `Date()`: WidgetKit archives func dayLabel(for date: Date) -> String {
/// 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: now) let today = cal.startOfDay(for: Date())
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))"
@@ -412,23 +410,35 @@ func dayLabel(for date: Date, relativeTo now: 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))"
} }
return WidgetDateFormatters.weekdayDate.string(from: date) let formatter = DateFormatter()
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 {
WidgetDateFormatters.shortDate.string(from: date) let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "dd.MM."
return f.string(from: date)
} }
func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String { func freshnessLabel(for fetchedAt: Date) -> String {
let cal = Calendar.current let cal = Calendar.current
let today = cal.startOfDay(for: now) let today = cal.startOfDay(for: Date())
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 WidgetDateFormatters.time.string(from: fetchedAt) return timeFmt.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 \(WidgetDateFormatters.time.string(from: fetchedAt))" return "gestern \(timeFmt.string(from: fetchedAt))"
} }
return WidgetDateFormatters.dateTime.string(from: fetchedAt) let dateTimeFmt = DateFormatter()
dateTimeFmt.locale = Locale(identifier: "de_DE")
dateTimeFmt.dateFormat = "dd.MM. HH:mm"
return dateTimeFmt.string(from: fetchedAt)
} }
@@ -1,279 +0,0 @@
import SwiftUI
import UIKit
// Layout constants and rules — must mirror WidgetRenderer.kt on Android,
// otherwise the platforms drift apart on the same widget size.
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
let FALLBACK_SLOT_MINUTES = 45
/// Trailing periods without lessons are cut off so the tiles get more
/// height — but never below this many periods, or a short day would blow up
/// to a handful of oversized tiles.
let MIN_VISIBLE_PERIODS = 6
let MIN_HOUR_HEIGHT: CGFloat = 18
let MAX_HOUR_HEIGHT: CGFloat = 72
let MIN_BLOCK_HEIGHT: CGFloat = 16
let LESSON_GAP: CGFloat = 1.5
let TITLE_ROW_HEIGHT: CGFloat = 20
let GRID_MARGIN_TOP: CGFloat = 4
let WEEK_HEADER_HEIGHT: CGFloat = 32
let RULER_WIDTH: CGFloat = 30
let TILE_MARGIN_H: CGFloat = 1
let TILE_PADDING_H: CGFloat = 4
let TILE_PADDING_V: CGFloat = 2
let TILE_CORNER_RADIUS: CGFloat = 7
// In-app tile: 15sp title over 10sp body lines. The widget keeps that ratio
// and scales both so title + room + teacher fill one period.
let TITLE_TO_BODY_RATIO: CGFloat = 1.5
let MAX_BODY_FONT: CGFloat = 10
let MIN_BODY_FONT: CGFloat = 6.5
let MIN_TITLE_FONT: CGFloat = 6.5
let MIN_BODY_LINE_FONT: CGFloat = 5.5
let LINE_BOX_PER_FONT: CGFloat = 1.2
/// If stacking room and teacher would push the body font below this, both
/// share one line instead so the text stays readable.
let STACKED_BODY_MIN_FONT: CGFloat = 7.5
/// A title that needs to shrink below this to fit on one line wraps onto a
/// second line instead, if the tile has the height for it.
let TITLE_WRAP_BELOW_FONT: CGFloat = 8
// Footprint of the "+N" badge in the tile's bottom trailing corner.
let BADGE_WIDTH: CGFloat = 16
let BADGE_HEIGHT: CGFloat = 11
let RULER_TIMES_MIN_CELL: CGFloat = 30
let BREAK_ICON_MIN_HEIGHT: CGFloat = 12
/// Space the holiday column keeps free above and below the rotated name for
/// the icon and the "Schulfrei" label.
let HOLIDAY_NAME_INSET: CGFloat = 44
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
guard !periods.isEmpty else { return CGFloat(realMin) }
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
}
let first = periods.first!
if realMin < first.startMinutes {
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
}
let last = periods.last!
if realMin > last.endMinutes {
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
}
var prev = first
for i in 1..<periods.count {
let curr = periods[i]
if realMin > prev.endMinutes && realMin < curr.startMinutes {
let gap = curr.startMinutes - prev.endMinutes
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
if gap > 0 {
return CGFloat(prev.virtualEndMinutes) +
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
}
return CGFloat(curr.virtualStartMinutes)
}
prev = curr
}
return 0
}
func minutesOfDay(_ date: Date) -> Int {
let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
return (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
}
func trimTrailingPeriods(_ periods: [WidgetPeriod], lessons: [WidgetLesson]) -> [WidgetPeriod] {
guard let latestEnd = lessons.map({ minutesOfDay($0.end) }).max() else { return periods }
let lastUsed = periods.lastIndex { $0.startMinutes < latestEnd } ?? -1
return Array(periods.prefix(max(lastUsed + 1, MIN_VISIBLE_PERIODS)))
}
func totalVirtualMinutes(_ periods: [WidgetPeriod]) -> Int {
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
}
func resolveHourHeight(forGridHeight height: CGFloat, periods: [WidgetPeriod]) -> CGFloat {
let virtualHours = max(CGFloat(totalVirtualMinutes(periods)) / 60, 1)
return max(MIN_HOUR_HEIGHT, min(MAX_HOUR_HEIGHT, height / virtualHours))
}
/// Period boundaries deduped: adjacent periods share a line, periods on
/// either side of a break get their own (bracketing the break block).
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
var seen = Set<Int>()
var result: [Int] = []
for p in periods {
for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
if seen.insert(v).inserted { result.append(v) }
}
}
return result.sorted()
}
struct PeriodBreak: Hashable {
let virtualStart: Int
let virtualMinutes: Int
}
/// Only big breaks (Hofpause, Mittagspause) survive the mapper's small-break
/// collapse as a virtual gap between two periods.
func periodBreaks(_ periods: [WidgetPeriod]) -> [PeriodBreak] {
guard periods.count > 1 else { return [] }
return (0..<periods.count - 1).compactMap { i in
let gap = periods[i + 1].virtualStartMinutes - periods[i].virtualEndMinutes
return gap > 0
? PeriodBreak(virtualStart: periods[i].virtualEndMinutes, virtualMinutes: gap)
: nil
}
}
/// Font and line-box sizes shared by every tile of one widget, derived from
/// the height of a single period.
struct TileMetrics {
let maxBodyLines: Int
let bodyFont: CGFloat
let titleBox: CGFloat
let bodyBox: CGFloat
init(periods: [WidgetPeriod], hourHeight: CGFloat) {
let slotMinutes = periods.first.map { $0.virtualEndMinutes - $0.virtualStartMinutes }
?? FALLBACK_SLOT_MINUTES
let slotHeight = CGFloat(slotMinutes) * hourHeight / 60
let inner = slotHeight - LESSON_GAP - 2 * TILE_PADDING_V
func font(lines: CGFloat) -> CGFloat {
inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines))
}
maxBodyLines = font(lines: 2) < STACKED_BODY_MIN_FONT ? 1 : 2
bodyFont = max(MIN_BODY_FONT, min(MAX_BODY_FONT, font(lines: CGFloat(maxBodyLines))))
titleBox = bodyFont * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT
bodyBox = bodyFont * LINE_BOX_PER_FONT
}
}
/// Distributes the tile height like the in-app _TileContent: the title comes
/// first, then as many body lines as fit.
struct TileTextLayout {
let title: String
let titleBox: CGFloat
let titleFont: CGFloat
let titleLines: Int
let body: [String]
/// Set when there is no room for a line below the title: the room moves
/// next to it instead.
let inline: String?
/// Index of the line (0 = title row) that has to stay clear of the "+N"
/// badge, or nil when the text ends above it.
let badgeLine: Int?
init(lesson: WidgetLesson, innerHeight: CGFloat, innerWidth: CGFloat, metrics: TileMetrics) {
title = !lesson.subjectShort.isEmpty ? lesson.subjectShort : (lesson.subjectLong ?? "—")
let candidates = [lesson.room, lesson.teacher ?? lesson.originalTeacher]
.compactMap { $0 }
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
let singleTitleBox = min(metrics.titleBox, innerHeight)
let capacity = max(
0,
min(metrics.maxBodyLines, Int((innerHeight - singleTitleBox) / metrics.bodyBox + 0.05))
)
if capacity >= candidates.count {
body = candidates
} else if capacity == 1 {
// Rather share one line than drop the teacher entirely.
body = [candidates.joined(separator: " · ")]
} else {
body = []
}
if capacity == 0, let first = candidates.first, !title.contains(first) {
inline = first
} else {
inline = nil
}
let spare = innerHeight - singleTitleBox - CGFloat(body.count) * metrics.bodyBox
if spare >= singleTitleBox,
let wrappedLine = TileTextLayout.wrappedTitleLineBox(
title, innerWidth: innerWidth, metrics: metrics
) {
titleBox = 2 * wrappedLine
titleFont = wrappedLine / LINE_BOX_PER_FONT
titleLines = 2
} else {
titleBox = singleTitleBox
titleFont = singleTitleBox / LINE_BOX_PER_FONT
titleLines = 1
}
let content = titleBox + CGFloat(body.count) * metrics.bodyBox
let hasBadge = (lesson.siblingCount ?? 0) > 0
badgeLine = hasBadge && innerHeight - content < BADGE_HEIGHT ? body.count : nil
}
/// Line box for a title wrapped onto two lines, or nil when it should
/// stay on one: it fits there at a readable size, or it has no word
/// boundary to break at. The box is capped so the longest word still
/// fits the tile width.
private static func wrappedTitleLineBox(
_ title: String,
innerWidth: CGFloat,
metrics: TileMetrics
) -> CGFloat? {
func widthPerFontPoint(_ text: String) -> CGFloat {
let font = UIFont.systemFont(ofSize: 100, weight: .medium)
return (text as NSString).size(withAttributes: [.font: font]).width / 100
}
if widthPerFontPoint(title) * TITLE_WRAP_BELOW_FONT <= innerWidth { return nil }
let words = title
.replacingOccurrences(of: "-", with: "- ")
.split(separator: " ")
.map(String.init)
guard words.count > 1, let widest = words.map(widthPerFontPoint).max(), widest > 0 else {
return nil
}
return min(metrics.titleBox, innerWidth / widest * LINE_BOX_PER_FONT)
}
}
extension Color {
/// Parses the `#RRGGBB` tile colour resolved in Dart.
init?(widgetHex hex: String?) {
guard let hex, hex.count == 7, hex.hasPrefix("#"),
let value = UInt32(hex.dropFirst(), radix: 16) else {
return nil
}
self.init(
red: Double((value >> 16) & 0xFF) / 255,
green: Double((value >> 8) & 0xFF) / 255,
blue: Double(value & 0xFF) / 255
)
}
}
extension WidgetLesson {
var tileColor: Color {
if let resolved = Color(widgetHex: color) { return resolved }
// Only reached for payloads written before Dart shipped the resolved
// tile colour; mirrors lesson_color.dart.
switch status {
// No time-based highlight: the widget timeline only ticks every
// ~30min, so "current" and "past" would be stale most of the day.
case .regular, .past, .ongoing:
return Color(red: 153 / 255, green: 51 / 255, blue: 51 / 255)
case .cancelled: return .black
case .irregular: return Color(red: 143 / 255, green: 25 / 255, blue: 179 / 255)
case .teacherChanged: return Color(red: 41 / 255, green: 99 / 255, blue: 155 / 255)
case .duty: return Color(red: 0, green: 121 / 255, blue: 107 / 255)
case .event:
return customColor != nil
? Color(red: 1, green: 160 / 255, blue: 0)
: Color(red: 46 / 255, green: 125 / 255, blue: 50 / 255)
}
}
}
@@ -20,44 +20,36 @@ struct TimetableWeekView: View {
@ViewBuilder @ViewBuilder
private func content(data: WidgetTimetableData) -> some View { private func content(data: WidgetTimetableData) -> some View {
let cal = Calendar.current
let days = (0..<5).map { offset in
cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
}
let weekLessons = data.lessons.filter { lesson in
days.contains { cal.isDate(lesson.start, inSameDayAs: $0) }
}
let periods = trimTrailingPeriods(data.periods, lessons: weekLessons)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
header(data: data) header(data: data)
dayHeaderRow(days: days) dayHeaderRow(data: data)
.padding(.top, GRID_MARGIN_TOP)
GeometryReader { geo in GeometryReader { geo in
let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods) let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
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) {
PeriodRuler(periods: periods, hourHeight: height) timeLabelsColumn(hourHeight: hourHeight, periods: data.periods)
.frame(width: 28, alignment: .topTrailing)
columnDivider columnDivider
ForEach(days.indices, id: \.self) { index in ForEach(0..<5, id: \.self) { offset in
TimeGridView( column(
lessons: weekLessons.filter { data: data,
cal.isDate($0.start, inSameDayAs: days[index]) offset: offset,
}, hourHeight: hourHeight,
periods: periods, subjectOnly: subjectOnly
hourHeight: height,
holiday: holiday(on: days[index], data: data),
isToday: cal.isDate(days[index], inSameDayAs: entry.date)
) )
if index < 4 { columnDivider } .frame(maxWidth: .infinity)
if offset < 4 { columnDivider }
} }
} }
} }
.padding(.top, 2)
}
}
private func holiday(on day: Date, data: WidgetTimetableData) -> WidgetDayInfo? {
data.days?.first {
$0.isHoliday && Calendar.current.isDate($0.date, inSameDayAs: day)
} }
} }
@@ -71,55 +63,115 @@ struct TimetableWeekView: View {
let cal = Calendar.current let cal = Calendar.current
let week = cal.component(.weekOfYear, from: data.anchorDate) let week = cal.component(.weekOfYear, from: data.anchorDate)
let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate
return HStack(spacing: 6) { return HStack {
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))") Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
.font(.system(size: 14, weight: .bold)) .font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.65) .minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, alignment: .leading) Spacer()
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
.font(.system(size: 10)) .font(.system(size: 9))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.fixedSize()
} }
.frame(height: TITLE_ROW_HEIGHT)
} }
/// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below, private func dayHeaderRow(data: WidgetTimetableData) -> some View {
/// today inside an accent circle. let cal = Calendar.current
private func dayHeaderRow(days: [Date]) -> some View { // `.frame(maxWidth: .infinity)` is critical: without an explicit
// greedy width (or a true `Spacer()` inside), this HStack collapses
// to the sum of its fixed-width children in a `.leading` VStack
// and the 5 day-columns end up sharing ~5pt of width — the labels
// crunch to the left and stop aligning with the grid columns below.
// The fixed height keeps `columnDivider`'s vertically-flexible // The fixed height keeps `columnDivider`'s vertically-flexible
// Rectangle from stealing space from the GeometryReader below. // Rectangle from stealing space from the GeometryReader.
HStack(spacing: 0) { return HStack(spacing: 0) {
Spacer().frame(width: RULER_WIDTH) Spacer().frame(width: 28)
columnDivider columnDivider
ForEach(days.indices, id: \.self) { index in ForEach(0..<5, id: \.self) { offset in
dayHeaderCell(days[index]) let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
.frame(maxWidth: .infinity) VStack(spacing: -1) {
if index < 4 { columnDivider } Text(weekday(for: day))
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textPrimary)
Text(shortDate(day))
.font(.system(size: 8))
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity)
if offset < 4 { columnDivider }
} }
} }
.frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT) // 20pt fits 10pt weekday + 8pt date with tight -1pt spacing.
.frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20)
} }
private func dayHeaderCell(_ day: Date) -> some View { private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View {
// `entry.date`, not `Date()` — WidgetKit archives entries ahead of let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
// time, the midnight entry has to mark its own day. let totalHeight = CGFloat(totalMin) * hourHeight / 60.0
let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date) // Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked
return VStack(spacing: 1) { // time+name no longer fits in one period slot and the two labels
Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased()) // visually clip into the next slot. Drop to a single bold period
.font(.system(size: 9, weight: .medium)) // number until there's room to read both lines.
.foregroundStyle(isToday ? palette.accent : palette.textSecondary) let compact = hourHeight < 26
.lineLimit(1) return ZStack(alignment: .topTrailing) {
Text("\(Calendar.current.component(.day, from: day))") ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
.font(.system(size: 11, weight: isToday ? .bold : .regular)) Rectangle()
.foregroundStyle(isToday ? palette.onAccent : palette.textPrimary) .fill(palette.divider)
.lineLimit(1) .frame(height: 1)
.frame(width: 18, height: 18) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
.background(Circle().fill(isToday ? palette.accent : Color.clear)) }
ForEach(periods, id: \.startMinutes) { period in
VStack(alignment: .trailing, spacing: -2) {
if compact {
Text("\(period.name).")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
} else {
Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60))
.font(.system(size: 9))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text("\(period.name).")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
}
.padding(.trailing, 4)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
}
} }
.frame(height: totalHeight, alignment: .topTrailing)
}
private func column(
data: WidgetTimetableData,
offset: Int,
hourHeight: CGFloat,
subjectOnly: Bool
) -> some View {
let cal = Calendar.current
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
let lessonsForDay = data.lessons.filter { cal.isDate($0.start, inSameDayAs: day) }
return TimeGridView(
lessons: lessonsForDay,
periods: data.periods,
hourHeight: hourHeight,
showRoom: !subjectOnly,
showTeacher: !subjectOnly,
showTimeLabels: false,
horizontalPadding: 3
)
}
private func weekday(for date: Date) -> String {
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,13 +41,11 @@ struct TimetableDayProvider: TimelineProvider {
in context: Context, in context: Context,
completion: @escaping (Timeline<TimetableEntry>) -> Void completion: @escaping (Timeline<TimetableEntry>) -> Void
) { ) {
let now = Date() let entry = TimetableEntry.current(variant: .day)
// 30 min mirrors the Dart workmanager cadence. iOS treats this as // 30 min mirrors the Dart workmanager cadence. iOS treats this as
// advisory; the boundary entries below keep the rendered day correct // advisory; the "Stand:" label tells the user when data is stale.
// even when no reload is granted, and the "Stand:" label tells the let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
// user when the underlying data is stale. completion(Timeline(entries: [entry], policy: .after(next)))
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
completion(Timeline(entries: TimetableEntry.dayEntries(now: now), policy: .after(next)))
} }
} }
@@ -82,9 +80,9 @@ struct TimetableWeekProvider: TimelineProvider {
in context: Context, in context: Context,
completion: @escaping (Timeline<TimetableEntry>) -> Void completion: @escaping (Timeline<TimetableEntry>) -> Void
) { ) {
let now = Date() let entry = TimetableEntry.current(variant: .week)
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next))) completion(Timeline(entries: [entry], policy: .after(next)))
} }
} }
@@ -122,58 +120,6 @@ 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 {
+1 -26
View File
@@ -10,15 +10,6 @@ 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 {
@@ -27,15 +18,10 @@ 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?
} }
@@ -47,12 +33,6 @@ 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
@@ -60,17 +40,12 @@ 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_v2" static let weekData = "widget_data_week_v1"
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,27 +9,15 @@ struct WidgetPalette {
let textPrimary: Color let textPrimary: Color
let textSecondary: Color let textSecondary: Color
let divider: Color let divider: Color
let accent: Color
let onAccent: Color
let breakBlock: Color let breakBlock: Color
let todayTint: Color
let holidayTint: Color
let watermarkOpacity: Double let watermarkOpacity: Double
// ColorScheme.fromSeed(#993333).primary of the app's light / dark theme.
private static let lightAccent = Color(red: 0x90 / 255, green: 0x4A / 255, blue: 0x47 / 255)
private static let darkAccent = Color(red: 0xFF / 255, green: 0xB3 / 255, blue: 0xAF / 255)
static let light = WidgetPalette( static let light = WidgetPalette(
background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255), background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255),
textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255), textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255),
textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255), textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255),
divider: Color.black.opacity(0x22 / 255.0), divider: Color.black.opacity(0x22 / 255.0),
accent: lightAccent, breakBlock: Color.black.opacity(0x0C / 255.0),
onAccent: .white,
breakBlock: lightAccent.opacity(0x32 / 255.0),
todayTint: lightAccent.opacity(0x0E / 255.0),
holidayTint: Color.black.opacity(0x32 / 255.0),
watermarkOpacity: 0.014 watermarkOpacity: 0.014
) )
@@ -38,11 +26,7 @@ struct WidgetPalette {
textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255), textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255),
textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255), textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255),
divider: Color.white.opacity(0x33 / 255.0), divider: Color.white.opacity(0x33 / 255.0),
accent: darkAccent, breakBlock: Color.white.opacity(0x14 / 255.0),
onAccent: Color(red: 0x57 / 255, green: 0x1D / 255, blue: 0x1D / 255),
breakBlock: darkAccent.opacity(0x32 / 255.0),
todayTint: darkAccent.opacity(0x0E / 255.0),
holidayTint: Color.white.opacity(0x32 / 255.0),
watermarkOpacity: 0.025 watermarkOpacity: 0.025
) )
-2
View File
@@ -1,2 +0,0 @@
app_identifier("eu.mhsl.marianum.mobile.client")
team_id("MY55VF3KPG")
-27
View File
@@ -1,27 +0,0 @@
default_platform(:ios)
# App-Store-Auslieferung über fastlane deliver. Die Screenshots erzeugt
# ../tool/screenshots_ios.sh (Demo-Login + integration_test) direkt in
# screenshots/de-DE/; deliver ordnet sie anhand der Pixelgröße iPhone 6,5″ bzw.
# iPad 13″ zu.
#
# Authentifizierung über einen App-Store-Connect-API-Key (JSON im fastlane-
# Format, siehe README.md). Pfad per APP_STORE_CONNECT_API_KEY setzen; die Datei
# selbst wird per .gitignore nicht eingecheckt.
platform :ios do
desc "Nur Screenshots hochladen (ersetzt die vorhandenen, verändert keine Texte/Binaries)"
lane :upload_screenshots do
upload_to_app_store(
api_key_path: ENV["APP_STORE_CONNECT_API_KEY"] || "fastlane/app-store-connect-api-key.json",
skip_binary_upload: true,
skip_metadata: true,
skip_app_version_update: true,
skip_screenshots: false,
overwrite_screenshots: true,
screenshots_path: "./fastlane/screenshots",
precheck_include_in_app_purchases: false,
run_precheck_before_submit: false,
force: true, # kein HTML-Vorschau-Bestätigungsschritt
)
end
end
-49
View File
@@ -1,49 +0,0 @@
# Fastlane – App-Store-Screenshots & Upload
iOS-Gegenstück zu `android/fastlane`. Die App muss dafür **nicht** neu gebaut
werden – der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte aus
Fixtures.
## 1. Screenshots aufnehmen
Aus dem **Client**-Wurzelverzeichnis:
```bash
tool/screenshots_ios.sh
```
Fährt „iPhone 14 Plus“ (6,5″, 1284×2778) und „iPad Pro 13-inch (M5)“
(13″, 2064×2752) nacheinander durch und legt die PNGs in
`materials/screenshots/{ios/6-5-inch,ipados/13-inch}/` sowie als JPEG-Upload-Kopie
(App Store Connect lehnt Alpha-Kanäle ab) in `screenshots/de-DE/` ab. Fehlende
Simulatoren legt das Skript selbst an.
- Nur eine Größe: `PROFILES="iphone" tool/screenshots_ios.sh`
- Andere Simulatoren: `IPHONE_SIM="iPhone 11 Pro Max" IPAD_SIM="…" tool/screenshots_ios.sh`
App und Schlüsselbund der verwendeten Simulatoren werden dabei zurückgesetzt.
## 2. Hochladen
fastlane installieren (`brew install fastlane`), in App Store Connect unter
*Benutzer und Zugriff → Integrationen → App Store Connect API* einen Key mit
Rolle *App-Manager* anlegen und als JSON ablegen:
```json
{
"key_id": "ABC123XYZ",
"issuer_id": "00000000-0000-0000-0000-000000000000",
"key": "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----",
"in_house": false
}
```
```bash
export APP_STORE_CONNECT_API_KEY=/pfad/zu/app-store-connect-api-key.json
cd ios
fastlane upload_screenshots
```
Die Screenshots landen in der aktuell bearbeitbaren App-Store-Version
(„Vorbereitung für Einreichung“); vorhandene Screenshots werden ersetzt. Der
API-Key wird **nicht** eingecheckt (siehe `.gitignore`).
+1
View File
@@ -0,0 +1 @@
class ApiRequest {}
-5
View File
@@ -7,9 +7,4 @@ abstract class ApiResponse {
@JsonKey(includeIfNull: false) @JsonKey(includeIfNull: false)
Map<String, String>? headers; Map<String, String>? headers;
/// Entries the decoder skipped (see `decodeListLenient`), reported by the
/// API layer once the response is back on the main isolate.
@JsonKey(includeFromJson: false, includeToJson: false)
List<String> parseWarnings = const [];
} }
-219
View File
@@ -1,219 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../session/session_manager.dart';
import '../utils/directory_size.dart';
/// One cached response: the raw JSON payload and when it was stored.
class CacheEntry {
final String json;
final int lastUpdate;
const CacheEntry({required this.json, required this.lastUpdate});
}
/// File-per-key store behind [RequestCache] and friends.
///
/// Replaces `localstore` for response caching: localstore reads and decodes
/// the entire collection synchronously on first access, keeps every document
/// in memory for the whole session and JSON-encodes payloads a second time.
/// Here every access is async and touches exactly one file, and the payload is
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
///
/// Every account has its own directory; all access goes to the one of the
/// active account, in every isolate.
class CacheStore {
CacheStore._();
static final CacheStore instance = CacheStore._();
/// Payloads above this size are decoded on a background isolate.
static const int isolateDecodeThreshold = 64 * 1024;
static const _directoryPrefix = 'request_cache';
final Map<String, Future<Directory>> _dirs = {};
final Map<String, Future<void>> _writes = {};
/// Directory name for an account's storage [namespace]; null while no
/// account is active. The account from before multi-account support
/// (namespace '') keeps the original directory.
@visibleForTesting
static String directoryName(String? namespace) => switch (namespace) {
null => '$_directoryPrefix-signed-out',
'' => _directoryPrefix,
_ => '$_directoryPrefix-$namespace',
};
Future<Directory> _directory() {
final name = directoryName(SessionManager().activeAccount?.namespace);
return _dirs[name] ??= () async {
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');
}
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'dart:convert';
import '../../connect_api.dart';
import 'login_request.dart';
import 'login_response.dart';
class Login extends ConnectApi<LoginResponse> {
final LoginRequest payload;
Login(this.payload) : super('auth/login');
@override
bool get requiresAuth => false;
@override
ConnectHttpMethod get method => ConnectHttpMethod.post;
@override
Object? get body => payload.toJson();
@override
LoginResponse assemble(String raw) =>
LoginResponse.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'login_request.g.dart';
@JsonSerializable()
class LoginRequest {
final String username;
final String password;
final String tokenName;
LoginRequest({
required this.username,
required this.password,
required this.tokenName,
});
Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
}
@@ -0,0 +1,20 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'login_request.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LoginRequest _$LoginRequestFromJson(Map<String, dynamic> json) => LoginRequest(
username: json['username'] as String,
password: json['password'] as String,
tokenName: json['tokenName'] as String,
);
Map<String, dynamic> _$LoginRequestToJson(LoginRequest instance) =>
<String, dynamic>{
'username': instance.username,
'password': instance.password,
'tokenName': instance.tokenName,
};

Some files were not shown because too many files have changed in this diff Show More