merged develop
This commit is contained in:
@@ -56,7 +56,7 @@ lib/
|
|||||||
|
|
||||||
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` – Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
|
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` – Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
|
||||||
|
|
||||||
**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box + Localstore-Collection); nur die Settings liegen in der globalen Box. 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.
|
**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; der Request-Cache `CacheStore` folgt dem aktiven Konto mit eigenem Verzeichnis); nur die Settings liegen in der globalen Box. `SessionManager().sessionEpoch` steigt bei jedem Kontowechsel – asynchrone Arbeit prüft damit, ob ihr Ergebnis noch zum aktiven Konto gehört. Was ein Konto außerhalb seines Speichers hinterlässt (Tray, Push-Schlüssel, Downloads), räumt `SessionWipe` auf. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert.
|
||||||
|
|
||||||
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`nextcloud`, `guardian`; `AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung.
|
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`nextcloud`, `guardian`; `AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung.
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTask"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
@@ -98,6 +98,7 @@
|
|||||||
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
|
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
|
||||||
|
android:enabled="@bool/widget_supported"
|
||||||
android:label="@string/widget_day_label"
|
android:label="@string/widget_day_label"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
|
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
|
||||||
|
android:enabled="@bool/widget_supported"
|
||||||
android:label="@string/widget_week_label"
|
android:label="@string/widget_week_label"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -1,15 +1,38 @@
|
|||||||
package eu.mhsl.marianum.mobile.client
|
package eu.mhsl.marianum.mobile.client
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
private val widgetChannel = "eu.mhsl.marianum.widget"
|
private val widgetChannel = "eu.mhsl.marianum.widget"
|
||||||
|
private val shareChannel = "eu.mhsl.marianum.share"
|
||||||
/// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation`
|
/// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation`
|
||||||
/// so the same intent isn't replayed on every resume.
|
/// so the same intent isn't replayed on every resume.
|
||||||
private var pendingTimetableTap: Boolean = false
|
private var pendingTimetableTap: Boolean = false
|
||||||
|
private val shareActions = setOf(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE)
|
||||||
|
|
||||||
|
/// receive_sharing_intent only emits warm shares to a live Dart listener and
|
||||||
|
/// does not buffer them. After a relaunch into an existing task the share
|
||||||
|
/// arrives via onNewIntent before Dart subscribed, so it is held here until
|
||||||
|
/// Dart reports `listenerReady`.
|
||||||
|
private var shareListenerReady = false
|
||||||
|
private var bufferedShare: Intent? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
// Android replays the original launch intent when the activity is
|
||||||
|
// recreated from recents or after process death. A share or widget tap
|
||||||
|
// that was already handled would otherwise run again — for shares with
|
||||||
|
// temp files that are long gone.
|
||||||
|
val replayed = savedInstanceState != null ||
|
||||||
|
(intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0
|
||||||
|
if (replayed && (intent.action in shareActions || isWidgetTap(intent))) {
|
||||||
|
intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
}
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
@@ -26,17 +49,35 @@ class MainActivity : FlutterActivity() {
|
|||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MethodChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
shareChannel
|
||||||
|
).setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"listenerReady" -> {
|
||||||
|
shareListenerReady = true
|
||||||
|
bufferedShare?.let { flutterEngine.activityControlSurface.onNewIntent(it) }
|
||||||
|
bufferedShare = null
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
consumeIntentData(intent)
|
consumeIntentData(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
|
if (!shareListenerReady && intent.action in shareActions) {
|
||||||
|
bufferedShare = intent
|
||||||
|
}
|
||||||
consumeIntentData(intent)
|
consumeIntentData(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isWidgetTap(intent: Intent?): Boolean =
|
||||||
|
intent?.getBooleanExtra("widget_open_timetable", false) == true
|
||||||
|
|
||||||
private fun consumeIntentData(intent: Intent?) {
|
private fun consumeIntentData(intent: Intent?) {
|
||||||
if (intent?.getBooleanExtra("widget_open_timetable", false) == true) {
|
if (isWidgetTap(intent)) pendingTimetableTap = true
|
||||||
pendingTimetableTap = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.util.TimeZone
|
|||||||
// Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names
|
// Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names
|
||||||
// must stay in sync.
|
// must stay in sync.
|
||||||
enum class WidgetLessonStatus {
|
enum class WidgetLessonStatus {
|
||||||
REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT;
|
REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT, DUTY;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
|
fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
|
||||||
@@ -22,6 +22,7 @@ enum class WidgetLessonStatus {
|
|||||||
"irregular" -> IRREGULAR
|
"irregular" -> IRREGULAR
|
||||||
"teacherChanged" -> TEACHER_CHANGED
|
"teacherChanged" -> TEACHER_CHANGED
|
||||||
"event" -> EVENT
|
"event" -> EVENT
|
||||||
|
"duty" -> DUTY
|
||||||
else -> REGULAR
|
else -> REGULAR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,9 @@ data class WidgetLesson(
|
|||||||
val originalTeacher: String?,
|
val originalTeacher: String?,
|
||||||
val status: WidgetLessonStatus,
|
val status: WidgetLessonStatus,
|
||||||
val customColor: String?,
|
val customColor: String?,
|
||||||
|
// Tile colour resolved in Dart (subject colours, palettes); null only for
|
||||||
|
// payloads written before the field existed.
|
||||||
|
val color: Int?,
|
||||||
val siblingCount: Int,
|
val siblingCount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,6 +54,12 @@ data class WidgetPeriod(
|
|||||||
val virtualEndMinutes: Int,
|
val virtualEndMinutes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class WidgetDayInfo(
|
||||||
|
val date: Date,
|
||||||
|
val isHoliday: Boolean,
|
||||||
|
val holidayName: String?,
|
||||||
|
)
|
||||||
|
|
||||||
data class WidgetTimetableData(
|
data class WidgetTimetableData(
|
||||||
val fetchedAt: Date,
|
val fetchedAt: Date,
|
||||||
val anchorDate: Date,
|
val anchorDate: Date,
|
||||||
@@ -57,6 +67,8 @@ data class WidgetTimetableData(
|
|||||||
val periods: List<WidgetPeriod>,
|
val periods: List<WidgetPeriod>,
|
||||||
val isHoliday: Boolean,
|
val isHoliday: Boolean,
|
||||||
val holidayName: String?,
|
val holidayName: String?,
|
||||||
|
// Week payload only; empty for the day payload.
|
||||||
|
val days: List<WidgetDayInfo>,
|
||||||
)
|
)
|
||||||
|
|
||||||
object WidgetDataParser {
|
object WidgetDataParser {
|
||||||
@@ -81,6 +93,11 @@ object WidgetDataParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseColor(raw: String?): Int? {
|
||||||
|
if (raw == null || !Regex("#[0-9A-Fa-f]{6}").matches(raw)) return null
|
||||||
|
return 0xFF000000.toInt() or raw.substring(1).toInt(16)
|
||||||
|
}
|
||||||
|
|
||||||
fun parse(json: String?): WidgetTimetableData? {
|
fun parse(json: String?): WidgetTimetableData? {
|
||||||
if (json.isNullOrEmpty()) return null
|
if (json.isNullOrEmpty()) return null
|
||||||
return try {
|
return try {
|
||||||
@@ -102,6 +119,7 @@ object WidgetDataParser {
|
|||||||
originalTeacher = obj.stringOrNull("originalTeacher"),
|
originalTeacher = obj.stringOrNull("originalTeacher"),
|
||||||
status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")),
|
status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")),
|
||||||
customColor = obj.stringOrNull("customColor"),
|
customColor = obj.stringOrNull("customColor"),
|
||||||
|
color = parseColor(obj.stringOrNull("color")),
|
||||||
siblingCount = obj.optInt("siblingCount", 0),
|
siblingCount = obj.optInt("siblingCount", 0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,6 +138,18 @@ object WidgetDataParser {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val daysArray = root.optJSONArray("days")
|
||||||
|
val days = mutableListOf<WidgetDayInfo>()
|
||||||
|
if (daysArray != null) {
|
||||||
|
for (i in 0 until daysArray.length()) {
|
||||||
|
val obj = daysArray.optJSONObject(i) ?: continue
|
||||||
|
days += WidgetDayInfo(
|
||||||
|
date = parseDate(obj.stringOrNull("date")) ?: continue,
|
||||||
|
isHoliday = obj.optBoolean("isHoliday", false),
|
||||||
|
holidayName = obj.stringOrNull("holidayName"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
WidgetTimetableData(
|
WidgetTimetableData(
|
||||||
fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(),
|
fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(),
|
||||||
anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(),
|
anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(),
|
||||||
@@ -127,6 +157,7 @@ object WidgetDataParser {
|
|||||||
periods = periods,
|
periods = periods,
|
||||||
isHoliday = root.optBoolean("isHoliday", false),
|
isHoliday = root.optBoolean("isHoliday", false),
|
||||||
holidayName = root.stringOrNull("holidayName"),
|
holidayName = root.stringOrNull("holidayName"),
|
||||||
|
days = days,
|
||||||
)
|
)
|
||||||
} catch (_: JSONException) {
|
} catch (_: JSONException) {
|
||||||
null
|
null
|
||||||
|
|||||||
+590
-331
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Material Icons "coffee_outlined", matches the in-app period ruler. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M18.52 3H6C4.92 3 3.98 3.89 3.98 5.02V10.69C3.98 14.53 6.94 17.91 10.78 18C14.72 18.14 18 14.95 18 11.02V9.98H18.52C20.44 9.98 21.98 8.44 21.98 6.52C21.98 4.55 20.44 3 18.52 3ZM15.98 5.02V8.02H6V5.02H15.98ZM15.98 9.98V11.02C15.98 13.78 13.78 15.98 11.02 15.98C8.25 15.98 6 13.78 6 11.02V9.98ZM18.52 8.02H18V5.02H18.52C19.31 5.02 20.02 5.67 20.02 6.52C20.02 7.31 19.31 8.02 18.52 8.02ZM3.98 18.98H20.02V21H3.98V18.98Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Material Icons "event_outlined", matches the in-app holiday region. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M18.98 3.98H18V2.02H15.98V3.98H8.02V2.02H6V3.98H5.02C3.89 3.98 3 4.92 3 6V20.02C3 21.09 3.89 21.98 5.02 21.98H18.98C20.11 21.98 21 21.09 21 20.02V6C21 4.92 20.11 3.98 18.98 3.98ZM18.98 20.02H5.02V9.98H18.98V20.02ZM18.98 8.02H5.02V6H18.98V8.02ZM12 12.98H17.02V18H12V12.98Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Material Icons "restaurant", matches the in-app break region. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M11.02 9H9V2.02H6.98V9H5.02V2.02H3V9C3 11.11 4.64 12.84 6.75 12.98V21.98H9.23V12.98C11.34 12.84 12.98 11.11 12.98 9V2.02H11.02V9ZM15.98 6V14.02H18.52V21.98H21V2.02C18.23 2.02 15.98 4.22 15.98 6Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Tinted at runtime with the tile colour resolved in Dart. -->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners android:radius="7dp" />
|
||||||
|
<solid android:color="@android:color/white" />
|
||||||
|
</shape>
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item>
|
|
||||||
<shape android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="@color/widget_lesson_cancelled" />
|
|
||||||
<stroke android:width="1.5dp" android:color="#C8FF0000" />
|
|
||||||
</shape>
|
|
||||||
</item>
|
|
||||||
<item
|
|
||||||
android:left="3dp"
|
|
||||||
android:top="3dp"
|
|
||||||
android:right="3dp"
|
|
||||||
android:bottom="3dp"
|
|
||||||
android:drawable="@drawable/widget_lesson_cancelled_x" />
|
|
||||||
</layer-list>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="#FF2196F3" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="#FF4CAF50" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="#FFEF6C00" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="@color/widget_lesson_irregular" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="@color/widget_lesson_ongoing" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="@color/widget_lesson_past" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="@color/widget_lesson_regular" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="@color/widget_lesson_teacher_changed" />
|
|
||||||
</shape>
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<corners android:radius="7dp" />
|
||||||
|
<solid android:color="@android:color/transparent" />
|
||||||
|
<stroke android:width="2dp" android:color="#C8F44336" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item
|
||||||
|
android:left="2dp"
|
||||||
|
android:top="2dp"
|
||||||
|
android:right="2dp"
|
||||||
|
android:bottom="2dp"
|
||||||
|
android:drawable="@drawable/widget_lesson_cancelled_x" />
|
||||||
|
</layer-list>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
android:viewportWidth="100"
|
android:viewportWidth="100"
|
||||||
android:viewportHeight="100">
|
android:viewportHeight="100">
|
||||||
<path
|
<path
|
||||||
android:strokeColor="#C8FF0000"
|
android:strokeColor="#C8F44336"
|
||||||
android:strokeWidth="5"
|
android:strokeWidth="2.5"
|
||||||
android:pathData="M0,0 L100,100 M100,0 L0,100" />
|
android:pathData="M0,0 L100,100 M100,0 L0,100" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:shape="rectangle">
|
android:shape="oval">
|
||||||
<corners android:radius="6dp" />
|
<solid android:color="@android:color/white" />
|
||||||
<solid android:color="#FF993333" />
|
|
||||||
</shape>
|
</shape>
|
||||||
@@ -3,6 +3,14 @@
|
|||||||
android:id="@+id/widget_break_block_root"
|
android:id="@+id/widget_break_block_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="14dp"
|
android:layout_height="14dp"
|
||||||
android:layout_marginTop="0dp"
|
android:layout_marginTop="0dp">
|
||||||
android:layout_marginStart="2dp"
|
|
||||||
android:layout_marginEnd="2dp" />
|
<ImageView
|
||||||
|
android:id="@+id/widget_break_block_icon"
|
||||||
|
android:layout_width="12dp"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/widget_icon_restaurant" />
|
||||||
|
</FrameLayout>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_break_label_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:layout_marginTop="0dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_break_label_icon"
|
||||||
|
android:layout_width="10dp"
|
||||||
|
android:layout_height="10dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/widget_icon_coffee" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
|
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
|
||||||
the actual widget content without disturbing the LinearLayout flow. -->
|
the actual widget content without disturbing the LinearLayout flow.
|
||||||
|
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
|
||||||
|
height from them, so they must not follow the system font scale. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_root"
|
android:id="@+id/widget_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@drawable/app_widget_background">
|
android:background="@drawable/app_widget_background"
|
||||||
|
tools:ignore="SpUsage,SmallSp">
|
||||||
|
|
||||||
<!-- Bottom-leading so the mark looks like it's peeking out of the lower
|
<!-- Width/height/end+bottom margins are overridden in the renderer so
|
||||||
left corner. Width/height/start+bottom margins are overridden in the
|
the mark scales with the widget size. -->
|
||||||
renderer so the mark scales with the widget size. -->
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/widget_watermark"
|
android:id="@+id/widget_watermark"
|
||||||
android:layout_width="160dp"
|
android:layout_width="160dp"
|
||||||
@@ -26,43 +28,68 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp">
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="20dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical">
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_day_title"
|
android:id="@+id/widget_day_title"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@color/widget_text_primary"
|
android:textColor="@color/widget_text_primary"
|
||||||
android:singleLine="true"
|
android:maxLines="1"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="9dp"
|
||||||
|
android:autoSizeMaxTextSize="14dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
tools:text="Heute · 08.05." />
|
tools:text="Heute · 08.05." />
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_day_subtitle"
|
android:id="@+id/widget_day_subtitle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="10sp"
|
android:layout_marginStart="6dp"
|
||||||
|
android:textSize="10dp"
|
||||||
android:textColor="@color/widget_text_secondary"
|
android:textColor="@color/widget_text_secondary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
tools:text="Stand: 14:32" />
|
tools:text="Stand: 14:32" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Narrow day widgets can't fit title and freshness side by side —
|
||||||
|
the renderer moves the freshness label down here instead. -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_day_subtitle_below"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:textSize="9dp"
|
||||||
|
android:textColor="@color/widget_text_secondary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
tools:text="Stand: 14:32" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="4dp"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_day_time_labels"
|
android:id="@+id/widget_day_time_labels"
|
||||||
android:layout_width="32dp"
|
android:layout_width="30dp"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static picker preview of the day widget, laid out like WidgetRenderer's output. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@color/widget_background">
|
android:background="@color/widget_background"
|
||||||
|
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="160dp"
|
android:layout_width="160dp"
|
||||||
@@ -20,164 +23,126 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp">
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
<LinearLayout
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="20dp"
|
||||||
android:orientation="horizontal"
|
android:gravity="start|center_vertical"
|
||||||
android:gravity="center_vertical">
|
android:includeFontPadding="false"
|
||||||
<TextView
|
android:textSize="14dp"
|
||||||
android:layout_width="0dp"
|
android:textStyle="bold"
|
||||||
android:layout_height="wrap_content"
|
android:textColor="@color/widget_text_primary"
|
||||||
android:layout_weight="1"
|
android:maxLines="1"
|
||||||
android:textSize="14sp"
|
android:text="Heute · 06.05." />
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:text="Heute · 06.05." />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="10sp"
|
|
||||||
android:textColor="@color/widget_text_secondary"
|
|
||||||
android:text="Stand: 07:50" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:orientation="horizontal"
|
android:layout_marginTop="4dp"
|
||||||
android:layout_marginTop="6dp">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
|
||||||
android:layout_width="32dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
android:layout_height="match_parent">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" />
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
|
||||||
android:layout_marginTop="22dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="1." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="50dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="2." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="79dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="3." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="115dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="4." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="142dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="5." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="169dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="6." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_weight="1">
|
|
||||||
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="29dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="56dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="83dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="115dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="143dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="170dp" android:background="@color/widget_divider" />
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="22dp"
|
|
||||||
android:layout_marginTop="29dp"
|
|
||||||
android:layout_marginStart="2dp"
|
|
||||||
android:layout_marginEnd="2dp"
|
|
||||||
android:padding="3dp"
|
|
||||||
android:background="@drawable/widget_lesson_block_regular">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@android:color/white" android:text="MA-LK" />
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="2" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:25" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="72dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="3" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:15" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp">
|
||||||
|
<ImageView android:layout_width="10dp" android:layout_height="10dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_text_secondary" android:src="@drawable/widget_icon_coffee" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="122dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:20" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="158dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:10" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="194dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:00" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="230dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:05" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:50" />
|
||||||
|
</FrameLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
android:layout_width="match_parent"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
android:layout_height="55dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
android:layout_marginTop="83dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
android:layout_marginStart="2dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
android:layout_marginEnd="2dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
android:padding="3dp"
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
android:background="@drawable/widget_lesson_block_regular">
|
</FrameLayout>
|
||||||
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
android:orientation="vertical">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
android:textColor="@android:color/white" android:text="DE-GK" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
android:textSize="9sp" android:textColor="#CCFFFFFF" android:text="B11" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
<FrameLayout
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
android:layout_width="match_parent"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
|
||||||
android:layout_height="22dp"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
|
||||||
android:layout_marginTop="143dp"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
|
||||||
android:layout_marginStart="2dp"
|
</LinearLayout>
|
||||||
android:layout_marginEnd="2dp"
|
|
||||||
android:padding="3dp"
|
|
||||||
android:background="@drawable/widget_lesson_block_cancelled">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@android:color/white" android:text="BIO" />
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
|
||||||
<FrameLayout
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
android:layout_width="match_parent"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
|
||||||
android:layout_height="22dp"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
|
||||||
android:layout_marginTop="170dp"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
|
||||||
android:layout_marginStart="2dp"
|
</LinearLayout>
|
||||||
android:layout_marginEnd="2dp"
|
</FrameLayout>
|
||||||
android:padding="3dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#000000">
|
||||||
android:background="@drawable/widget_lesson_block_teacher_changed">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
|
||||||
android:textColor="@android:color/white" android:text="GE" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
|
||||||
|
</LinearLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="230.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#29639B">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
|
||||||
|
holiday name rotated along the column. The name's width is set by the
|
||||||
|
renderer — after the rotation it is the length along the column. -->
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/widget_holiday_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:ignore="SpUsage,SmallSp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center_horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_holiday_icon"
|
||||||
|
android:layout_width="14dp"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/widget_icon_event" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_holiday_label"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:paddingStart="2dp"
|
||||||
|
android:paddingEnd="2dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/widget_school_free"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="6dp"
|
||||||
|
android:autoSizeMaxTextSize="9dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_holiday_name"
|
||||||
|
android:layout_width="120dp"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:rotation="90"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:letterSpacing="0.1"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="7dp"
|
||||||
|
android:autoSizeMaxTextSize="11dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
|
tools:text="Herbstferien" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -1,90 +1,113 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Mirrors AppointmentTile: title on top, room and teacher stacked below,
|
||||||
|
each line shrinking on its own to fit the tile width. Text sizes are dp
|
||||||
|
(not sp) because the line boxes are sized in dp by the renderer. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_lesson_block_root"
|
android:id="@+id/widget_lesson_block_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="40dp"
|
android:layout_height="40dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="1dp"
|
||||||
android:layout_marginEnd="2dp"
|
android:layout_marginEnd="1dp"
|
||||||
android:layout_marginTop="0dp"
|
android:layout_marginTop="0dp"
|
||||||
android:paddingStart="7dp"
|
android:background="@drawable/widget_lesson_block"
|
||||||
android:paddingEnd="7dp"
|
tools:ignore="SpUsage,SmallSp">
|
||||||
android:paddingTop="3dp"
|
|
||||||
android:paddingBottom="3dp"
|
|
||||||
android:background="@drawable/widget_lesson_block_regular">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="horizontal"
|
android:orientation="vertical"
|
||||||
android:gravity="top"
|
android:paddingStart="4dp"
|
||||||
android:baselineAligned="false">
|
android:paddingEnd="4dp"
|
||||||
|
android:paddingTop="2dp"
|
||||||
|
android:paddingBottom="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/widget_lesson_title_row"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:baselineAligned="false">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_lesson_subject"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:fontFamily="sans-serif-medium"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="6.5dp"
|
||||||
|
android:autoSizeMaxTextSize="15dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
|
tools:text="MA-LK" />
|
||||||
|
|
||||||
|
<!-- Only for tiles too short for a body line below the title. -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_lesson_inline"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
android:layout_marginStart="3dp"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="7dp"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:text="B11" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_lesson_subject"
|
android:id="@+id/widget_lesson_room"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="10dp"
|
||||||
android:layout_weight="1"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:gravity="top|start"
|
android:gravity="start|center_vertical"
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
android:autoSizeTextType="uniform"
|
android:autoSizeTextType="uniform"
|
||||||
android:autoSizeMinTextSize="5sp"
|
android:autoSizeMinTextSize="5.5dp"
|
||||||
android:autoSizeMaxTextSize="11sp"
|
android:autoSizeMaxTextSize="10dp"
|
||||||
android:autoSizeStepGranularity="1sp"
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
tools:text="MA-LK" />
|
tools:text="A12" />
|
||||||
|
|
||||||
<LinearLayout
|
<TextView
|
||||||
android:id="@+id/widget_lesson_secondary_stack"
|
android:id="@+id/widget_lesson_teacher"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="10dp"
|
||||||
android:layout_weight="1"
|
android:textColor="@android:color/white"
|
||||||
android:orientation="vertical"
|
android:maxLines="1"
|
||||||
android:gravity="top|end"
|
android:ellipsize="end"
|
||||||
android:layout_marginStart="4dp">
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
<TextView
|
android:autoSizeTextType="uniform"
|
||||||
android:id="@+id/widget_lesson_room"
|
android:autoSizeMinTextSize="5.5dp"
|
||||||
android:layout_width="wrap_content"
|
android:autoSizeMaxTextSize="10dp"
|
||||||
android:layout_height="12dp"
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
android:textColor="#CCFFFFFF"
|
tools:text="Müller" />
|
||||||
android:maxLines="1"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:gravity="end|top"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:autoSizeTextType="uniform"
|
|
||||||
android:autoSizeMinTextSize="5sp"
|
|
||||||
android:autoSizeMaxTextSize="11sp"
|
|
||||||
android:autoSizeStepGranularity="1sp"
|
|
||||||
tools:text="A12" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/widget_lesson_teacher"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="12dp"
|
|
||||||
android:textColor="#B3FFFFFF"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:gravity="end|top"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:autoSizeTextType="uniform"
|
|
||||||
android:autoSizeMinTextSize="5sp"
|
|
||||||
android:autoSizeMaxTextSize="11sp"
|
|
||||||
android:autoSizeStepGranularity="1sp"
|
|
||||||
tools:text="Müller" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/widget_lesson_cancelled"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/widget_lesson_cancelled_overlay"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_lesson_sibling_badge"
|
android:id="@+id/widget_lesson_sibling_badge"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="bottom|start"
|
android:layout_gravity="bottom|end"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:layout_marginBottom="2dp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textSize="12sp"
|
android:textSize="9dp"
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
|
|||||||
@@ -1,31 +1,50 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<!-- One period cell of the ruler, mirroring the in-app _PeriodLabel: number
|
||||||
|
centred, start time above, end time below. -->
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_time_label_root"
|
android:id="@+id/widget_time_label_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="36dp"
|
||||||
android:layout_marginTop="0dp"
|
android:layout_marginTop="0dp"
|
||||||
android:orientation="vertical"
|
tools:ignore="SpUsage,SmallSp">
|
||||||
android:gravity="end"
|
|
||||||
android:paddingEnd="3dp">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_time_label_time"
|
android:id="@+id/widget_time_label_start"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="9sp"
|
android:layout_gravity="top"
|
||||||
android:textColor="@color/widget_text_primary"
|
android:layout_marginTop="2dp"
|
||||||
android:singleLine="true"
|
android:gravity="center_horizontal"
|
||||||
android:lineSpacingExtra="-2dp"
|
android:textSize="7dp"
|
||||||
|
android:textColor="@color/widget_text_secondary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
tools:text="07:55" />
|
tools:text="07:55" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_time_label_number"
|
android:id="@+id/widget_time_label_number"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="7sp"
|
android:layout_gravity="center_vertical"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:fontFamily="sans-serif-medium"
|
||||||
|
android:textSize="11dp"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
tools:text="1" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_time_label_end"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:layout_marginBottom="2dp"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:textSize="7dp"
|
||||||
android:textColor="@color/widget_text_secondary"
|
android:textColor="@color/widget_text_secondary"
|
||||||
android:singleLine="true"
|
android:maxLines="1"
|
||||||
android:lineSpacingExtra="-2dp"
|
android:includeFontPadding="false"
|
||||||
tools:text="1." />
|
tools:text="08:40" />
|
||||||
</LinearLayout>
|
</FrameLayout>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
|
||||||
|
the actual widget content without disturbing the LinearLayout flow.
|
||||||
|
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
|
||||||
|
height from them, so they must not follow the system font scale. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_root"
|
android:id="@+id/widget_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@drawable/app_widget_background">
|
android:background="@drawable/app_widget_background"
|
||||||
|
tools:ignore="SpUsage,SmallSp">
|
||||||
|
|
||||||
|
<!-- Width/height/end+bottom margins are overridden in the renderer so
|
||||||
|
the mark scales with the widget size. -->
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/widget_watermark"
|
android:id="@+id/widget_watermark"
|
||||||
android:layout_width="160dp"
|
android:layout_width="160dp"
|
||||||
@@ -21,42 +28,53 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp">
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="20dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical">
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_week_title"
|
android:id="@+id/widget_week_title"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@color/widget_text_primary"
|
android:textColor="@color/widget_text_primary"
|
||||||
android:singleLine="true"
|
android:maxLines="1"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="9dp"
|
||||||
|
android:autoSizeMaxTextSize="14dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
tools:text="KW 19 · 06.05.–10.05." />
|
tools:text="KW 19 · 06.05.–10.05." />
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_week_subtitle"
|
android:id="@+id/widget_week_subtitle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="10sp"
|
android:layout_marginStart="6dp"
|
||||||
|
android:textSize="10dp"
|
||||||
android:textColor="@color/widget_text_secondary"
|
android:textColor="@color/widget_text_secondary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
tools:text="Stand: 14:32" />
|
tools:text="Stand: 14:32" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="32dp"
|
||||||
android:orientation="horizontal"
|
android:layout_marginTop="4dp"
|
||||||
android:layout_marginTop="6dp">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:layout_width="28dp"
|
android:layout_width="30dp"
|
||||||
android:layout_height="wrap_content" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:layout_width="1dp"
|
android:layout_width="1dp"
|
||||||
@@ -66,7 +84,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_mon"
|
android:id="@+id/widget_week_header_mon"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@@ -77,7 +95,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_tue"
|
android:id="@+id/widget_week_header_tue"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@@ -88,7 +106,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_wed"
|
android:id="@+id/widget_week_header_wed"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@@ -99,7 +117,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_thu"
|
android:id="@+id/widget_week_header_thu"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@@ -110,7 +128,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_fri"
|
android:id="@+id/widget_week_header_fri"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -123,7 +141,7 @@
|
|||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_time_labels"
|
android:id="@+id/widget_week_time_labels"
|
||||||
android:layout_width="28dp"
|
android:layout_width="30dp"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
|
|||||||
@@ -1,28 +1,35 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
|
||||||
|
today inside an accent circle. -->
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_week_day_header_root"
|
android:id="@+id/widget_week_day_header_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingTop="2dp"
|
tools:ignore="SpUsage,SmallSp">
|
||||||
android:paddingBottom="3dp">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_week_day_header_weekday"
|
android:id="@+id/widget_week_day_header_weekday"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="11sp"
|
android:fontFamily="sans-serif-medium"
|
||||||
android:textStyle="bold"
|
android:textSize="9dp"
|
||||||
android:textColor="@color/widget_text_primary"
|
android:textColor="@color/widget_text_secondary"
|
||||||
tools:text="Mo" />
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
tools:text="MO." />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_week_day_header_date"
|
android:id="@+id/widget_week_day_header_date"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="18dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="18dp"
|
||||||
android:textSize="9sp"
|
android:layout_marginTop="1dp"
|
||||||
android:textColor="@color/widget_text_secondary"
|
android:gravity="center"
|
||||||
tools:text="06.05." />
|
android:textSize="11dp"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
tools:text="6" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Static preview for the week widget. Five day columns with two short
|
<!-- Static picker preview of the week widget, laid out like WidgetRenderer's output. -->
|
||||||
demo blocks each so the structure is recognisable in the picker. -->
|
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@color/widget_background">
|
android:background="@color/widget_background"
|
||||||
|
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="200dp"
|
android:layout_width="200dp"
|
||||||
@@ -22,124 +23,312 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp">
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="14dp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="KW 19 · 04.05.–08.05." />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="32dp"
|
||||||
android:orientation="horizontal"
|
android:layout_marginTop="4dp"
|
||||||
android:gravity="center_vertical">
|
android:orientation="horizontal">
|
||||||
<TextView
|
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent" />
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:text="KW 19 · 06.05.–10.05." />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="10sp"
|
|
||||||
android:textColor="@color/widget_text_secondary"
|
|
||||||
android:text="Stand: 07:50" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- Day-name + date row -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:layout_marginTop="6dp">
|
|
||||||
<FrameLayout android:layout_width="20dp" android:layout_height="wrap_content" />
|
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mo" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MO." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="06.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Di" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DI." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="07.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mi" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MI." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="08.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Do" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DO." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="09.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Fr" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="FR." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="10.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="8" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Time grid: time-label column + 5 day columns -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:orientation="horizontal"
|
android:layout_marginTop="2dp"
|
||||||
android:layout_marginTop="2dp">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<FrameLayout android:layout_width="20dp" android:layout_height="match_parent">
|
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="0dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="08" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="36dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="10" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="72dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="12" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
|
||||||
</FrameLayout>
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" />
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
|
||||||
|
|
||||||
<!-- Mon -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="2" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:25" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="72dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="3" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:15" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp">
|
||||||
|
<ImageView android:layout_width="10dp" android:layout_height="10dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_text_secondary" android:src="@drawable/widget_icon_coffee" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="122dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:20" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="158dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:10" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="194dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:00" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="230dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:05" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:50" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<!-- Tue -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_cancelled">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="BIO" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="60dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="DE" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#000000">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
|
||||||
|
</LinearLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<!-- Wed -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="32dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#26A69A">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="SPO" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="TH1" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BAU" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#8F19B3">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PoWi" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A105" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="HOF" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="84.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#29639B">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<!-- Thu -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_teacher_changed">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="GE" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="PH" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#E91E63">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="CH" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N305" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MEI" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="194.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<!-- Fri -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#78909C">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EK" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A301" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LAN" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#7E57C2">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MU" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="M1" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SIN" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#F4511E">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="KU" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="K2" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MAL" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#FFA000">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="LA" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A108" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="CIC" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -4,4 +4,6 @@
|
|||||||
<color name="widget_text_primary">#FFF1F1F1</color>
|
<color name="widget_text_primary">#FFF1F1F1</color>
|
||||||
<color name="widget_text_secondary">#FFB0B0B0</color>
|
<color name="widget_text_secondary">#FFB0B0B0</color>
|
||||||
<color name="widget_divider">#33FFFFFF</color>
|
<color name="widget_divider">#33FFFFFF</color>
|
||||||
|
<color name="widget_preview_accent">#FFFFB3AF</color>
|
||||||
|
<color name="widget_preview_break">#32FFB3AF</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<bool name="widget_supported">true</bool>
|
||||||
|
</resources>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string>
|
<string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string>
|
||||||
<string name="widget_no_lessons">Keine Stunden</string>
|
<string name="widget_no_lessons">Keine Stunden</string>
|
||||||
<string name="widget_holiday">Ferien</string>
|
<string name="widget_holiday">Ferien</string>
|
||||||
|
<string name="widget_school_free">Schulfrei</string>
|
||||||
<string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string>
|
<string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string>
|
||||||
<string name="widget_loading">Lade…</string>
|
<string name="widget_loading">Lade…</string>
|
||||||
<string name="widget_status_label">Stand: %1$s</string>
|
<string name="widget_status_label">Stand: %1$s</string>
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Status colors mirror lib/view/pages/timetable/data/lesson_color.dart
|
|
||||||
exactly so widget tiles match the in-app calendar. -->
|
|
||||||
<color name="widget_lesson_regular">#FF993333</color>
|
|
||||||
<color name="widget_lesson_ongoing">#FFC83333</color>
|
|
||||||
<color name="widget_lesson_past">#FF993333</color>
|
|
||||||
<color name="widget_lesson_cancelled">#FF000000</color>
|
|
||||||
<color name="widget_lesson_irregular">#FF8F19B3</color>
|
|
||||||
<color name="widget_lesson_teacher_changed">#FF29639B</color>
|
|
||||||
<color name="widget_lesson_event">#FF2E7D32</color>
|
|
||||||
|
|
||||||
<color name="widget_background">#FFFCF7F5</color>
|
<color name="widget_background">#FFFCF7F5</color>
|
||||||
<color name="widget_text_primary">#FF111111</color>
|
<color name="widget_text_primary">#FF111111</color>
|
||||||
<color name="widget_text_secondary">#FF555555</color>
|
<color name="widget_text_secondary">#FF555555</color>
|
||||||
<color name="widget_divider">#22000000</color>
|
<color name="widget_divider">#22000000</color>
|
||||||
|
<color name="widget_preview_accent">#FF904A47</color>
|
||||||
|
<color name="widget_preview_break">#32904A47</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- WidgetRenderer positions every tile with RemoteViews calls that only
|
||||||
|
exist from Android 12 (API 31). Below that the widgets are not
|
||||||
|
offered at all rather than showing up empty. -->
|
||||||
|
<bool name="widget_supported">false</bool>
|
||||||
|
</resources>
|
||||||
@@ -180,11 +180,11 @@ AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = <key>` +
|
|||||||
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
|
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
|
||||||
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
|
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
|
||||||
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
|
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
|
||||||
| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) |
|
| `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) |
|
||||||
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
|
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
|
||||||
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
|
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
|
||||||
|
|
||||||
> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage
|
> `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers
|
||||||
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
|
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
|
||||||
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
|
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
|
||||||
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
|
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
|
||||||
|
|||||||
@@ -13,21 +13,44 @@ class SceneDelegate: FlutterSceneDelegate {
|
|||||||
options connectionOptions: UIScene.ConnectionOptions
|
options connectionOptions: UIScene.ConnectionOptions
|
||||||
) {
|
) {
|
||||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||||
for context in connectionOptions.urlContexts {
|
var handledShare = false
|
||||||
|
for context in connectionOptions.urlContexts where isShareUrl(context.url) {
|
||||||
_ = ReceiveSharingIntentPlugin.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>) {
|
||||||
for context in URLContexts {
|
let shares = URLContexts.filter { isShareUrl($0.url) }
|
||||||
|
for context in shares {
|
||||||
_ = ReceiveSharingIntentPlugin.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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,17 +152,54 @@ final class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func copyIntoAppGroup(src: URL) -> URL? {
|
// loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell.
|
||||||
|
private let fileQueue = DispatchQueue(label: "share.files")
|
||||||
|
|
||||||
|
// Ein Ordner pro Share: Gleichnamige Dateien (z. B. bearbeitete Fotos, alle
|
||||||
|
// "FullSizeRender.jpg") überschrieben sich sonst gegenseitig, auch über
|
||||||
|
// zwei Shares hinweg. Die App löscht den Ordner, sobald der Share erledigt ist.
|
||||||
|
private lazy var shareDir: URL? = {
|
||||||
guard let container = FileManager.default
|
guard let container = FileManager.default
|
||||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
|
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
|
let root = container.appendingPathComponent("share_intent", isDirectory: true)
|
||||||
let dst = container.appendingPathComponent(name)
|
sweepStaleShares(in: root)
|
||||||
|
let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Shares, deren App-Flow nie abgeschlossen wurde (Prozess beendet o. ä.),
|
||||||
|
// räumt sonst niemand weg — der App-Group-Container wird nie geleert.
|
||||||
|
private func sweepStaleShares(in root: URL) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard let entries = try? fm.contentsOfDirectory(
|
||||||
|
at: root, includingPropertiesForKeys: [.creationDateKey]) else { return }
|
||||||
|
let cutoff = Date().addingTimeInterval(-24 * 60 * 60)
|
||||||
|
for entry in entries {
|
||||||
|
let created = (try? entry.resourceValues(forKeys: [.creationDateKey]))?.creationDate
|
||||||
|
if let created, created < cutoff { try? fm.removeItem(at: entry) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyIntoAppGroup(src: URL) -> URL? {
|
||||||
|
fileQueue.sync { copyIntoShareDir(src: src) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func copyIntoShareDir(src: URL) -> URL? {
|
||||||
|
guard let dir = shareDir else { return nil }
|
||||||
|
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
|
||||||
|
var dst = dir.appendingPathComponent(name)
|
||||||
|
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares.
|
||||||
|
if FileManager.default.fileExists(atPath: dst.path) {
|
||||||
|
dst = dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)")
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
if FileManager.default.fileExists(atPath: dst.path) {
|
|
||||||
try FileManager.default.removeItem(at: dst)
|
|
||||||
}
|
|
||||||
try FileManager.default.copyItem(at: src, to: dst)
|
try FileManager.default.copyItem(at: src, to: dst)
|
||||||
return dst
|
return dst
|
||||||
} catch {
|
} catch {
|
||||||
@@ -171,10 +208,9 @@ final class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func writePng(image: UIImage) -> URL? {
|
private func writePng(image: UIImage) -> URL? {
|
||||||
guard let container = FileManager.default
|
guard let data = image.pngData(),
|
||||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId),
|
let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
|
||||||
let data = image.pngData() else { return nil }
|
let dst = dir.appendingPathComponent("\(UUID().uuidString).png")
|
||||||
let dst = container.appendingPathComponent("\(UUID().uuidString).png")
|
|
||||||
return (try? data.write(to: dst)) != nil ? dst : nil
|
return (try? data.write(to: dst)) != nil ? dst : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
// Layout constants — must mirror WidgetRenderer.kt on Android, otherwise
|
|
||||||
// the platforms drift apart on the same widget size.
|
|
||||||
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
|
|
||||||
let MIN_HOUR_HEIGHT: CGFloat = 18
|
|
||||||
let MAX_HOUR_HEIGHT: CGFloat = 72
|
|
||||||
let MIN_BLOCK_HEIGHT: CGFloat = 16
|
|
||||||
let LESSON_GAP: CGFloat = 1.5
|
|
||||||
|
|
||||||
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
|
|
||||||
guard !periods.isEmpty else { return CGFloat(realMin) }
|
|
||||||
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
|
|
||||||
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
|
|
||||||
}
|
|
||||||
let first = periods.first!
|
|
||||||
if realMin < first.startMinutes {
|
|
||||||
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
|
|
||||||
}
|
|
||||||
let last = periods.last!
|
|
||||||
if realMin > last.endMinutes {
|
|
||||||
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
|
|
||||||
}
|
|
||||||
var prev = first
|
|
||||||
for i in 1..<periods.count {
|
|
||||||
let curr = periods[i]
|
|
||||||
if realMin > prev.endMinutes && realMin < curr.startMinutes {
|
|
||||||
let gap = curr.startMinutes - prev.endMinutes
|
|
||||||
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
|
|
||||||
if gap > 0 {
|
|
||||||
return CGFloat(prev.virtualEndMinutes) +
|
|
||||||
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
|
|
||||||
}
|
|
||||||
return CGFloat(curr.virtualStartMinutes)
|
|
||||||
}
|
|
||||||
prev = curr
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Below these tile heights the secondary stack collapses progressively:
|
|
||||||
// shorter than ROOM_MIN drops everything, between ROOM_MIN and
|
|
||||||
// TEACHER_SEPARATE_MIN keeps the room but drops the teacher.
|
|
||||||
let BLOCK_SHOW_ROOM_MIN: CGFloat = 13
|
|
||||||
let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 20
|
|
||||||
|
|
||||||
let MIN_SUBJECT_FONT: CGFloat = 9
|
|
||||||
let MAX_SUBJECT_FONT: CGFloat = 14
|
|
||||||
let MIN_SECONDARY_FONT: CGFloat = 7
|
|
||||||
|
|
||||||
func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat {
|
|
||||||
let t = max(0, min(1, (hourHeight - MIN_HOUR_HEIGHT) / (MAX_HOUR_HEIGHT - MIN_HOUR_HEIGHT)))
|
|
||||||
return MIN_SUBJECT_FONT + t * (MAX_SUBJECT_FONT - MIN_SUBJECT_FONT)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TimetableDayView: View {
|
struct TimetableDayView: View {
|
||||||
let entry: TimetableEntry
|
let entry: TimetableEntry
|
||||||
@Environment(\.widgetPalette) private var palette
|
@Environment(\.widgetPalette) private var palette
|
||||||
@@ -73,7 +20,7 @@ struct TimetableDayView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func content(data: WidgetTimetableData) -> some View {
|
private func content(data: WidgetTimetableData) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) {
|
||||||
header(data: data)
|
header(data: data)
|
||||||
if data.isHoliday {
|
if data.isHoliday {
|
||||||
emptyState(text: data.holidayName ?? "Ferien")
|
emptyState(text: data.holidayName ?? "Ferien")
|
||||||
@@ -81,37 +28,41 @@ struct TimetableDayView: View {
|
|||||||
emptyState(text: "Keine Stunden")
|
emptyState(text: "Keine Stunden")
|
||||||
} else {
|
} else {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
|
let periods = trimTrailingPeriods(data.periods, lessons: data.lessons)
|
||||||
TimeGridView(
|
HStack(alignment: .top, spacing: 0) {
|
||||||
lessons: data.lessons,
|
PeriodRuler(
|
||||||
periods: data.periods,
|
periods: periods,
|
||||||
hourHeight: max(
|
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
|
||||||
MIN_HOUR_HEIGHT,
|
)
|
||||||
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
|
Rectangle()
|
||||||
),
|
.fill(palette.divider)
|
||||||
showRoom: true,
|
.frame(width: 1)
|
||||||
showTeacher: true,
|
TimeGridView(
|
||||||
showTimeLabels: true
|
lessons: data.lessons,
|
||||||
)
|
periods: periods,
|
||||||
|
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func header(data: WidgetTimetableData) -> some View {
|
private func header(data: WidgetTimetableData) -> some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 6) {
|
||||||
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
|
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.65)
|
||||||
Spacer(minLength: 4)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.fixedSize()
|
||||||
}
|
}
|
||||||
|
.frame(height: TITLE_ROW_HEIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func emptyState(text: String) -> some View {
|
private func emptyState(text: String) -> some View {
|
||||||
@@ -139,54 +90,23 @@ struct TimetableDayView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TimeGridView: View {
|
/// Mirrors the in-app _PeriodLabel: period number centred, start time above,
|
||||||
let lessons: [WidgetLesson]
|
/// end time below, a cup in the breaks.
|
||||||
|
struct PeriodRuler: View {
|
||||||
let periods: [WidgetPeriod]
|
let periods: [WidgetPeriod]
|
||||||
let hourHeight: CGFloat
|
let hourHeight: CGFloat
|
||||||
let showRoom: Bool
|
|
||||||
let showTeacher: Bool
|
|
||||||
let showTimeLabels: Bool
|
|
||||||
/// Week-widget passes 3 for narrow columns; day-widget keeps 7.
|
|
||||||
var horizontalPadding: CGFloat = 7
|
|
||||||
|
|
||||||
@Environment(\.widgetPalette) private var palette
|
@Environment(\.widgetPalette) private var palette
|
||||||
|
|
||||||
private var totalVirtualMinutes: Int {
|
|
||||||
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
|
|
||||||
}
|
|
||||||
|
|
||||||
private var totalHeight: CGFloat {
|
private var totalHeight: CGFloat {
|
||||||
CGFloat(totalVirtualMinutes) * hourHeight / 60.0
|
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Below this per-hour height the two-line label collapses to a single
|
|
||||||
/// period number — time + number overlap otherwise.
|
|
||||||
private var compactLabels: Bool { hourHeight < 26 }
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
// Every child has layout-y 0 (`.offset` only shifts the rendering),
|
||||||
if showTimeLabels {
|
// so both the ZStack and its frame need `.top` — the default
|
||||||
// 28pt matches the Week widget's time-label column so the
|
// `.center` would anchor the whole ruler at the column's midpoint.
|
||||||
// visual rhythm is identical across both widgets.
|
ZStack(alignment: .top) {
|
||||||
timeLabelsColumn
|
|
||||||
.frame(width: 28, alignment: .topTrailing)
|
|
||||||
Rectangle()
|
|
||||||
.fill(palette.divider)
|
|
||||||
.frame(width: 1)
|
|
||||||
}
|
|
||||||
ZStack(alignment: .top) {
|
|
||||||
gridLines
|
|
||||||
breakBlocks
|
|
||||||
ForEach(lessons.indices, id: \.self) { idx in
|
|
||||||
lessonBlock(lessons[idx])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var timeLabelsColumn: some View {
|
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(palette.divider)
|
.fill(palette.divider)
|
||||||
@@ -194,39 +114,91 @@ struct TimeGridView: View {
|
|||||||
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
|
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
|
||||||
}
|
}
|
||||||
ForEach(periods, id: \.startMinutes) { period in
|
ForEach(periods, id: \.startMinutes) { period in
|
||||||
VStack(alignment: .trailing, spacing: -2) {
|
cell(period)
|
||||||
if compactLabels {
|
}
|
||||||
Text("\(period.name).")
|
ForEach(periodBreaks(periods), id: \.self) { pause in
|
||||||
.font(.system(size: 9, weight: .bold))
|
let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
|
||||||
.foregroundStyle(palette.textPrimary)
|
if height >= BREAK_ICON_MIN_HEIGHT {
|
||||||
.lineLimit(1)
|
Image(systemName: "cup.and.saucer")
|
||||||
} else {
|
.font(.system(size: 8))
|
||||||
Text(formatHm(period.startMinutes))
|
.foregroundStyle(palette.textSecondary)
|
||||||
.font(.system(size: 9))
|
.frame(width: RULER_WIDTH, height: height)
|
||||||
.foregroundStyle(palette.textPrimary)
|
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
|
||||||
.lineLimit(1)
|
|
||||||
Text("\(period.name).")
|
|
||||||
.font(.system(size: 7, weight: .bold))
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.trailing, 4)
|
|
||||||
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: totalHeight, alignment: .topTrailing)
|
.frame(width: RULER_WIDTH, height: totalHeight, alignment: .top)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cell(_ period: WidgetPeriod) -> some View {
|
||||||
|
let height = CGFloat(period.virtualEndMinutes - period.virtualStartMinutes) * hourHeight / 60.0
|
||||||
|
let timeFont = max(6, min(8.5, height * 0.19))
|
||||||
|
return ZStack {
|
||||||
|
Text(period.name)
|
||||||
|
.font(.system(size: max(8, min(13, height * 0.3)), weight: .medium))
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
if height >= RULER_TIMES_MIN_CELL {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text(formatHm(period.startMinutes))
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(formatHm(period.endMinutes))
|
||||||
|
}
|
||||||
|
.font(.system(size: timeFont))
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: RULER_WIDTH, height: height)
|
||||||
|
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatHm(_ minutes: Int) -> String {
|
||||||
|
String(format: "%02d:%02d", minutes / 60, minutes % 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One day column: grid lines, break bands and the lesson tiles. Shared by
|
||||||
|
/// the day widget and each of the week widget's five columns.
|
||||||
|
struct TimeGridView: View {
|
||||||
|
let lessons: [WidgetLesson]
|
||||||
|
let periods: [WidgetPeriod]
|
||||||
|
let hourHeight: CGFloat
|
||||||
|
var holiday: WidgetDayInfo? = nil
|
||||||
|
var isToday: Bool = false
|
||||||
|
|
||||||
|
@Environment(\.widgetPalette) private var palette
|
||||||
|
|
||||||
|
private var totalHeight: CGFloat {
|
||||||
|
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let metrics = TileMetrics(periods: periods, hourHeight: hourHeight)
|
||||||
|
let innerWidth = geo.size.width - 2 * (TILE_MARGIN_H + TILE_PADDING_H)
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
if let holiday {
|
||||||
|
holidayColumn(holiday)
|
||||||
|
} else {
|
||||||
|
if isToday {
|
||||||
|
palette.todayTint.frame(height: totalHeight)
|
||||||
|
}
|
||||||
|
gridLines
|
||||||
|
breakBlocks
|
||||||
|
}
|
||||||
|
ForEach(lessons.indices, id: \.self) { idx in
|
||||||
|
lessonBlock(lessons[idx], metrics: metrics, innerWidth: innerWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: geo.size.width, height: totalHeight, alignment: .top)
|
||||||
|
}
|
||||||
|
.frame(height: totalHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var gridLines: some View {
|
private var gridLines: some View {
|
||||||
// `.frame(height: totalHeight, alignment: .top)` — without the
|
// `.top` on both the ZStack and the frame — see PeriodRuler.
|
||||||
// explicit `.top`, SwiftUI's frame modifier defaults to `.center`.
|
|
||||||
// Every Rectangle here has layout-y=0 (the `.offset()`s shift the
|
|
||||||
// rendered position only, not layout), so the inner ZStack's
|
|
||||||
// natural height is just 1pt; a 1pt ZStack centred in a 300pt
|
|
||||||
// frame anchors at the middle, and every line then renders
|
|
||||||
// relative to that midpoint — the grid visually starts halfway
|
|
||||||
// down the column and the later periods clip past the bottom.
|
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@@ -239,166 +211,173 @@ struct TimeGridView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var breakBlocks: some View {
|
private var breakBlocks: some View {
|
||||||
// Same `.center`-vs-`.top` bug as gridLines — see comment above.
|
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
ForEach(0..<max(0, periods.count - 1), id: \.self) { i in
|
ForEach(periodBreaks(periods), id: \.self) { pause in
|
||||||
let curr = periods[i]
|
let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
|
||||||
let next = periods[i + 1]
|
ZStack {
|
||||||
let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes
|
palette.breakBlock
|
||||||
if virtualGap > 0 {
|
if height >= BREAK_ICON_MIN_HEIGHT {
|
||||||
Rectangle()
|
Image(systemName: "fork.knife")
|
||||||
.fill(palette.breakBlock)
|
.font(.system(size: 9))
|
||||||
.frame(height: CGFloat(virtualGap) * hourHeight / 60.0)
|
.foregroundStyle(palette.accent)
|
||||||
.padding(.horizontal, 1)
|
}
|
||||||
.offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0)
|
|
||||||
}
|
}
|
||||||
|
.frame(height: height)
|
||||||
|
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: totalHeight, alignment: .top)
|
.frame(height: totalHeight, alignment: .top)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatHm(_ minutes: Int) -> String {
|
/// Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
|
||||||
String(format: "%02d:%02d", minutes / 60, minutes % 60)
|
/// holiday name rotated along the column.
|
||||||
|
private func holidayColumn(_ holiday: WidgetDayInfo) -> some View {
|
||||||
|
let nameLength = max(40, totalHeight - 2 * HOLIDAY_NAME_INSET)
|
||||||
|
return ZStack {
|
||||||
|
palette.holidayTint
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text("Schulfrei")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.6)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
|
// The outer frame carries the rotated footprint: rotationEffect
|
||||||
|
// alone leaves the layout box at its unrotated size.
|
||||||
|
Text(holiday.holidayName ?? "")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.kerning(1)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.6)
|
||||||
|
.frame(width: nameLength, height: 14)
|
||||||
|
.rotationEffect(.degrees(90))
|
||||||
|
.frame(width: 14, height: nameLength)
|
||||||
|
}
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.frame(height: totalHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func lessonBlock(_ lesson: WidgetLesson) -> some View {
|
private func lessonBlock(
|
||||||
let cal = Calendar.current
|
_ lesson: WidgetLesson,
|
||||||
let comps = cal.dateComponents([.hour, .minute], from: lesson.start)
|
metrics: TileMetrics,
|
||||||
let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
innerWidth: CGFloat
|
||||||
|
) -> some View {
|
||||||
|
let startMinutes = minutesOfDay(lesson.start)
|
||||||
let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60))
|
let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60))
|
||||||
let virtualStart = realMinutesToVirtual(startMinutes, periods: periods)
|
let virtualStart = realMinutesToVirtual(startMinutes, periods: periods)
|
||||||
let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods)
|
let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods)
|
||||||
|
|
||||||
if virtualEnd > virtualStart {
|
if virtualEnd > virtualStart {
|
||||||
|
// Half the gap above + half below so the grid line under the
|
||||||
|
// tile stays visible.
|
||||||
let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2
|
let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2
|
||||||
let height = max(
|
let height = max(
|
||||||
MIN_BLOCK_HEIGHT,
|
MIN_BLOCK_HEIGHT,
|
||||||
(virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP
|
(virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP
|
||||||
)
|
)
|
||||||
let subjectSize = subjectFont(forHourHeight: hourHeight)
|
LessonTile(
|
||||||
let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2)
|
lesson: lesson,
|
||||||
let room = lesson.room
|
height: height,
|
||||||
let teacher = lesson.teacher ?? lesson.originalTeacher
|
metrics: metrics,
|
||||||
let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false)
|
layout: TileTextLayout(
|
||||||
HStack(alignment: .top, spacing: 4) {
|
lesson: lesson,
|
||||||
Text(subjectLabel(lesson))
|
innerHeight: height - 2 * TILE_PADDING_V,
|
||||||
.font(.system(size: subjectSize, weight: .semibold))
|
innerWidth: innerWidth,
|
||||||
.foregroundStyle(.white)
|
metrics: metrics
|
||||||
.lineLimit(hasSecondary ? 1 : 2)
|
)
|
||||||
.minimumScaleFactor(0.5)
|
)
|
||||||
if hasSecondary {
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
VStack(alignment: .trailing, spacing: -1) {
|
|
||||||
if showRoom && height >= BLOCK_SHOW_ROOM_MIN {
|
|
||||||
if let room, !room.isEmpty {
|
|
||||||
Text(room)
|
|
||||||
.font(.system(size: secondarySize))
|
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
}
|
|
||||||
if showTeacher,
|
|
||||||
height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN,
|
|
||||||
let teacher,
|
|
||||||
!teacher.isEmpty {
|
|
||||||
Text(teacher)
|
|
||||||
.font(.system(size: secondarySize))
|
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, horizontalPadding)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
||||||
.frame(height: height, alignment: .topLeading)
|
|
||||||
.background(blockColor(lesson))
|
|
||||||
.cornerRadius(6)
|
|
||||||
.overlay(alignment: .bottomLeading) {
|
|
||||||
// Separate fixed-size badge so the +N hint stays readable
|
|
||||||
// when the subject autoshrinks on narrow tiles.
|
|
||||||
if let count = lesson.siblingCount, count > 0 {
|
|
||||||
Text("+\(count)")
|
|
||||||
.font(.system(size: 12, weight: .bold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.padding(.leading, horizontalPadding)
|
|
||||||
.padding(.bottom, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.overlay {
|
|
||||||
// CrossPainter parity: clip cross to the rounded shape so
|
|
||||||
// the diagonals don't bleed past the corners.
|
|
||||||
if lesson.status == .cancelled {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.stroke(Color.red.opacity(0.78), lineWidth: 1.5)
|
|
||||||
GeometryReader { geo in
|
|
||||||
Path { p in
|
|
||||||
p.move(to: .zero)
|
|
||||||
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
|
|
||||||
p.move(to: CGPoint(x: geo.size.width, y: 0))
|
|
||||||
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
|
|
||||||
}
|
|
||||||
.stroke(Color.red.opacity(0.78), lineWidth: 3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, showRoom ? 2 : 1)
|
|
||||||
.offset(y: top)
|
.offset(y: top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func subjectLabel(_ lesson: WidgetLesson) -> String {
|
|
||||||
!lesson.subjectShort.isEmpty
|
|
||||||
? lesson.subjectShort
|
|
||||||
: (lesson.subjectLong ?? "—")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mirrors lesson_color.dart + custom_event_colors.dart so the widget
|
|
||||||
/// matches the in-app calendar exactly.
|
|
||||||
private func blockColor(_ lesson: WidgetLesson) -> Color {
|
|
||||||
if lesson.status == .event, let custom = lesson.customColor {
|
|
||||||
switch custom {
|
|
||||||
case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
|
|
||||||
case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
|
|
||||||
case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0)
|
|
||||||
case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch lesson.status {
|
|
||||||
// `.ongoing` deliberately collapses into the regular red — the
|
|
||||||
// widget timeline only ticks every ~30min, so highlighting the
|
|
||||||
// "current" lesson would be stale most of the day and misleads
|
|
||||||
// more than it helps.
|
|
||||||
case .regular, .past, .ongoing:
|
|
||||||
return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
|
|
||||||
case .cancelled: return .black
|
|
||||||
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
|
|
||||||
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
|
|
||||||
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
|
|
||||||
case .duty: return Color(red: 0/255.0, green: 121/255.0, blue: 107/255.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Period boundaries deduped: adjacent periods share a line, periods on
|
/// Mirrors the in-app AppointmentTile: title on top, room and teacher
|
||||||
/// either side of a break get their own (bracketing the break block).
|
/// stacked below, each line shrinking on its own to fit the tile width.
|
||||||
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
|
private struct LessonTile: View {
|
||||||
var seen = Set<Int>()
|
let lesson: WidgetLesson
|
||||||
var result: [Int] = []
|
let height: CGFloat
|
||||||
for p in periods {
|
let metrics: TileMetrics
|
||||||
for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
|
let layout: TileTextLayout
|
||||||
if seen.insert(v).inserted { result.append(v) }
|
|
||||||
|
private static let cancelledRed =
|
||||||
|
Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let shape = RoundedRectangle(cornerRadius: TILE_CORNER_RADIUS)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
titleRow
|
||||||
|
ForEach(Array(layout.body.enumerated()), id: \.offset) { index, line in
|
||||||
|
Text(line)
|
||||||
|
.font(.system(size: metrics.bodyFont))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(MIN_BODY_LINE_FONT / metrics.bodyFont)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.frame(height: metrics.bodyBox)
|
||||||
|
.padding(.trailing, badgeInset(line: index + 1))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, TILE_PADDING_H)
|
||||||
|
.padding(.vertical, TILE_PADDING_V)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
.frame(height: height, alignment: .topLeading)
|
||||||
|
.background(lesson.tileColor)
|
||||||
|
.clipShape(shape)
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
if let count = lesson.siblingCount, count > 0 {
|
||||||
|
Text("+\(count)")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.trailing, TILE_PADDING_H)
|
||||||
|
.padding(.bottom, TILE_PADDING_V)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if lesson.status == .cancelled {
|
||||||
|
ZStack {
|
||||||
|
shape.strokeBorder(LessonTile.cancelledRed, lineWidth: 2)
|
||||||
|
GeometryReader { geo in
|
||||||
|
Path { p in
|
||||||
|
p.move(to: .zero)
|
||||||
|
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
|
||||||
|
p.move(to: CGPoint(x: geo.size.width, y: 0))
|
||||||
|
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
|
||||||
|
}
|
||||||
|
.stroke(LessonTile.cancelledRed, lineWidth: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(shape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, TILE_MARGIN_H)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titleRow: some View {
|
||||||
|
HStack(alignment: .center, spacing: 3) {
|
||||||
|
Text(layout.title)
|
||||||
|
.font(.system(size: layout.titleFont, weight: .medium))
|
||||||
|
.lineLimit(layout.titleLines)
|
||||||
|
.minimumScaleFactor(min(1, MIN_TITLE_FONT / layout.titleFont))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
if let inline = layout.inline {
|
||||||
|
Text(inline)
|
||||||
|
.font(.system(size: metrics.bodyFont))
|
||||||
|
.lineLimit(1)
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: layout.titleBox)
|
||||||
|
.padding(.trailing, badgeInset(line: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func badgeInset(line: Int) -> CGFloat {
|
||||||
|
layout.badgeLine == line ? BADGE_WIDTH : 0
|
||||||
}
|
}
|
||||||
return result.sorted()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fixed-locale formatters cached once — DateFormatter setup is the
|
/// Fixed-locale formatters cached once — DateFormatter setup is the
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// Layout constants and rules — must mirror WidgetRenderer.kt on Android,
|
||||||
|
// otherwise the platforms drift apart on the same widget size.
|
||||||
|
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
|
||||||
|
let FALLBACK_SLOT_MINUTES = 45
|
||||||
|
|
||||||
|
/// Trailing periods without lessons are cut off so the tiles get more
|
||||||
|
/// height — but never below this many periods, or a short day would blow up
|
||||||
|
/// to a handful of oversized tiles.
|
||||||
|
let MIN_VISIBLE_PERIODS = 6
|
||||||
|
|
||||||
|
let MIN_HOUR_HEIGHT: CGFloat = 18
|
||||||
|
let MAX_HOUR_HEIGHT: CGFloat = 72
|
||||||
|
let MIN_BLOCK_HEIGHT: CGFloat = 16
|
||||||
|
let LESSON_GAP: CGFloat = 1.5
|
||||||
|
|
||||||
|
let TITLE_ROW_HEIGHT: CGFloat = 20
|
||||||
|
let GRID_MARGIN_TOP: CGFloat = 4
|
||||||
|
let WEEK_HEADER_HEIGHT: CGFloat = 32
|
||||||
|
let RULER_WIDTH: CGFloat = 30
|
||||||
|
|
||||||
|
let TILE_MARGIN_H: CGFloat = 1
|
||||||
|
let TILE_PADDING_H: CGFloat = 4
|
||||||
|
let TILE_PADDING_V: CGFloat = 2
|
||||||
|
let TILE_CORNER_RADIUS: CGFloat = 7
|
||||||
|
|
||||||
|
// In-app tile: 15sp title over 10sp body lines. The widget keeps that ratio
|
||||||
|
// and scales both so title + room + teacher fill one period.
|
||||||
|
let TITLE_TO_BODY_RATIO: CGFloat = 1.5
|
||||||
|
let MAX_BODY_FONT: CGFloat = 10
|
||||||
|
let MIN_BODY_FONT: CGFloat = 6.5
|
||||||
|
let MIN_TITLE_FONT: CGFloat = 6.5
|
||||||
|
let MIN_BODY_LINE_FONT: CGFloat = 5.5
|
||||||
|
let LINE_BOX_PER_FONT: CGFloat = 1.2
|
||||||
|
|
||||||
|
/// If stacking room and teacher would push the body font below this, both
|
||||||
|
/// share one line instead so the text stays readable.
|
||||||
|
let STACKED_BODY_MIN_FONT: CGFloat = 7.5
|
||||||
|
|
||||||
|
/// A title that needs to shrink below this to fit on one line wraps onto a
|
||||||
|
/// second line instead, if the tile has the height for it.
|
||||||
|
let TITLE_WRAP_BELOW_FONT: CGFloat = 8
|
||||||
|
|
||||||
|
// Footprint of the "+N" badge in the tile's bottom trailing corner.
|
||||||
|
let BADGE_WIDTH: CGFloat = 16
|
||||||
|
let BADGE_HEIGHT: CGFloat = 11
|
||||||
|
|
||||||
|
let RULER_TIMES_MIN_CELL: CGFloat = 30
|
||||||
|
let BREAK_ICON_MIN_HEIGHT: CGFloat = 12
|
||||||
|
|
||||||
|
/// Space the holiday column keeps free above and below the rotated name for
|
||||||
|
/// the icon and the "Schulfrei" label.
|
||||||
|
let HOLIDAY_NAME_INSET: CGFloat = 44
|
||||||
|
|
||||||
|
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
|
||||||
|
guard !periods.isEmpty else { return CGFloat(realMin) }
|
||||||
|
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
|
||||||
|
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
|
||||||
|
}
|
||||||
|
let first = periods.first!
|
||||||
|
if realMin < first.startMinutes {
|
||||||
|
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
|
||||||
|
}
|
||||||
|
let last = periods.last!
|
||||||
|
if realMin > last.endMinutes {
|
||||||
|
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
|
||||||
|
}
|
||||||
|
var prev = first
|
||||||
|
for i in 1..<periods.count {
|
||||||
|
let curr = periods[i]
|
||||||
|
if realMin > prev.endMinutes && realMin < curr.startMinutes {
|
||||||
|
let gap = curr.startMinutes - prev.endMinutes
|
||||||
|
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
|
||||||
|
if gap > 0 {
|
||||||
|
return CGFloat(prev.virtualEndMinutes) +
|
||||||
|
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
|
||||||
|
}
|
||||||
|
return CGFloat(curr.virtualStartMinutes)
|
||||||
|
}
|
||||||
|
prev = curr
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func minutesOfDay(_ date: Date) -> Int {
|
||||||
|
let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
|
||||||
|
return (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimTrailingPeriods(_ periods: [WidgetPeriod], lessons: [WidgetLesson]) -> [WidgetPeriod] {
|
||||||
|
guard let latestEnd = lessons.map({ minutesOfDay($0.end) }).max() else { return periods }
|
||||||
|
let lastUsed = periods.lastIndex { $0.startMinutes < latestEnd } ?? -1
|
||||||
|
return Array(periods.prefix(max(lastUsed + 1, MIN_VISIBLE_PERIODS)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalVirtualMinutes(_ periods: [WidgetPeriod]) -> Int {
|
||||||
|
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveHourHeight(forGridHeight height: CGFloat, periods: [WidgetPeriod]) -> CGFloat {
|
||||||
|
let virtualHours = max(CGFloat(totalVirtualMinutes(periods)) / 60, 1)
|
||||||
|
return max(MIN_HOUR_HEIGHT, min(MAX_HOUR_HEIGHT, height / virtualHours))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Period boundaries deduped: adjacent periods share a line, periods on
|
||||||
|
/// either side of a break get their own (bracketing the break block).
|
||||||
|
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
|
||||||
|
var seen = Set<Int>()
|
||||||
|
var result: [Int] = []
|
||||||
|
for p in periods {
|
||||||
|
for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
|
||||||
|
if seen.insert(v).inserted { result.append(v) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PeriodBreak: Hashable {
|
||||||
|
let virtualStart: Int
|
||||||
|
let virtualMinutes: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only big breaks (Hofpause, Mittagspause) survive the mapper's small-break
|
||||||
|
/// collapse as a virtual gap between two periods.
|
||||||
|
func periodBreaks(_ periods: [WidgetPeriod]) -> [PeriodBreak] {
|
||||||
|
guard periods.count > 1 else { return [] }
|
||||||
|
return (0..<periods.count - 1).compactMap { i in
|
||||||
|
let gap = periods[i + 1].virtualStartMinutes - periods[i].virtualEndMinutes
|
||||||
|
return gap > 0
|
||||||
|
? PeriodBreak(virtualStart: periods[i].virtualEndMinutes, virtualMinutes: gap)
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Font and line-box sizes shared by every tile of one widget, derived from
|
||||||
|
/// the height of a single period.
|
||||||
|
struct TileMetrics {
|
||||||
|
let maxBodyLines: Int
|
||||||
|
let bodyFont: CGFloat
|
||||||
|
let titleBox: CGFloat
|
||||||
|
let bodyBox: CGFloat
|
||||||
|
|
||||||
|
init(periods: [WidgetPeriod], hourHeight: CGFloat) {
|
||||||
|
let slotMinutes = periods.first.map { $0.virtualEndMinutes - $0.virtualStartMinutes }
|
||||||
|
?? FALLBACK_SLOT_MINUTES
|
||||||
|
let slotHeight = CGFloat(slotMinutes) * hourHeight / 60
|
||||||
|
let inner = slotHeight - LESSON_GAP - 2 * TILE_PADDING_V
|
||||||
|
func font(lines: CGFloat) -> CGFloat {
|
||||||
|
inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines))
|
||||||
|
}
|
||||||
|
maxBodyLines = font(lines: 2) < STACKED_BODY_MIN_FONT ? 1 : 2
|
||||||
|
bodyFont = max(MIN_BODY_FONT, min(MAX_BODY_FONT, font(lines: CGFloat(maxBodyLines))))
|
||||||
|
titleBox = bodyFont * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT
|
||||||
|
bodyBox = bodyFont * LINE_BOX_PER_FONT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distributes the tile height like the in-app _TileContent: the title comes
|
||||||
|
/// first, then as many body lines as fit.
|
||||||
|
struct TileTextLayout {
|
||||||
|
let title: String
|
||||||
|
let titleBox: CGFloat
|
||||||
|
let titleFont: CGFloat
|
||||||
|
let titleLines: Int
|
||||||
|
let body: [String]
|
||||||
|
/// Set when there is no room for a line below the title: the room moves
|
||||||
|
/// next to it instead.
|
||||||
|
let inline: String?
|
||||||
|
/// Index of the line (0 = title row) that has to stay clear of the "+N"
|
||||||
|
/// badge, or nil when the text ends above it.
|
||||||
|
let badgeLine: Int?
|
||||||
|
|
||||||
|
init(lesson: WidgetLesson, innerHeight: CGFloat, innerWidth: CGFloat, metrics: TileMetrics) {
|
||||||
|
title = !lesson.subjectShort.isEmpty ? lesson.subjectShort : (lesson.subjectLong ?? "—")
|
||||||
|
let candidates = [lesson.room, lesson.teacher ?? lesson.originalTeacher]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||||
|
|
||||||
|
let singleTitleBox = min(metrics.titleBox, innerHeight)
|
||||||
|
let capacity = max(
|
||||||
|
0,
|
||||||
|
min(metrics.maxBodyLines, Int((innerHeight - singleTitleBox) / metrics.bodyBox + 0.05))
|
||||||
|
)
|
||||||
|
if capacity >= candidates.count {
|
||||||
|
body = candidates
|
||||||
|
} else if capacity == 1 {
|
||||||
|
// Rather share one line than drop the teacher entirely.
|
||||||
|
body = [candidates.joined(separator: " · ")]
|
||||||
|
} else {
|
||||||
|
body = []
|
||||||
|
}
|
||||||
|
if capacity == 0, let first = candidates.first, !title.contains(first) {
|
||||||
|
inline = first
|
||||||
|
} else {
|
||||||
|
inline = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let spare = innerHeight - singleTitleBox - CGFloat(body.count) * metrics.bodyBox
|
||||||
|
if spare >= singleTitleBox,
|
||||||
|
let wrappedLine = TileTextLayout.wrappedTitleLineBox(
|
||||||
|
title, innerWidth: innerWidth, metrics: metrics
|
||||||
|
) {
|
||||||
|
titleBox = 2 * wrappedLine
|
||||||
|
titleFont = wrappedLine / LINE_BOX_PER_FONT
|
||||||
|
titleLines = 2
|
||||||
|
} else {
|
||||||
|
titleBox = singleTitleBox
|
||||||
|
titleFont = singleTitleBox / LINE_BOX_PER_FONT
|
||||||
|
titleLines = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = titleBox + CGFloat(body.count) * metrics.bodyBox
|
||||||
|
let hasBadge = (lesson.siblingCount ?? 0) > 0
|
||||||
|
badgeLine = hasBadge && innerHeight - content < BADGE_HEIGHT ? body.count : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Line box for a title wrapped onto two lines, or nil when it should
|
||||||
|
/// stay on one: it fits there at a readable size, or it has no word
|
||||||
|
/// boundary to break at. The box is capped so the longest word still
|
||||||
|
/// fits the tile width.
|
||||||
|
private static func wrappedTitleLineBox(
|
||||||
|
_ title: String,
|
||||||
|
innerWidth: CGFloat,
|
||||||
|
metrics: TileMetrics
|
||||||
|
) -> CGFloat? {
|
||||||
|
func widthPerFontPoint(_ text: String) -> CGFloat {
|
||||||
|
let font = UIFont.systemFont(ofSize: 100, weight: .medium)
|
||||||
|
return (text as NSString).size(withAttributes: [.font: font]).width / 100
|
||||||
|
}
|
||||||
|
if widthPerFontPoint(title) * TITLE_WRAP_BELOW_FONT <= innerWidth { return nil }
|
||||||
|
let words = title
|
||||||
|
.replacingOccurrences(of: "-", with: "- ")
|
||||||
|
.split(separator: " ")
|
||||||
|
.map(String.init)
|
||||||
|
guard words.count > 1, let widest = words.map(widthPerFontPoint).max(), widest > 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return min(metrics.titleBox, innerWidth / widest * LINE_BOX_PER_FONT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
/// Parses the `#RRGGBB` tile colour resolved in Dart.
|
||||||
|
init?(widgetHex hex: String?) {
|
||||||
|
guard let hex, hex.count == 7, hex.hasPrefix("#"),
|
||||||
|
let value = UInt32(hex.dropFirst(), radix: 16) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.init(
|
||||||
|
red: Double((value >> 16) & 0xFF) / 255,
|
||||||
|
green: Double((value >> 8) & 0xFF) / 255,
|
||||||
|
blue: Double(value & 0xFF) / 255
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WidgetLesson {
|
||||||
|
var tileColor: Color {
|
||||||
|
if let resolved = Color(widgetHex: color) { return resolved }
|
||||||
|
// Only reached for payloads written before Dart shipped the resolved
|
||||||
|
// tile colour; mirrors lesson_color.dart.
|
||||||
|
switch status {
|
||||||
|
// No time-based highlight: the widget timeline only ticks every
|
||||||
|
// ~30min, so "current" and "past" would be stale most of the day.
|
||||||
|
case .regular, .past, .ongoing:
|
||||||
|
return Color(red: 153 / 255, green: 51 / 255, blue: 51 / 255)
|
||||||
|
case .cancelled: return .black
|
||||||
|
case .irregular: return Color(red: 143 / 255, green: 25 / 255, blue: 179 / 255)
|
||||||
|
case .teacherChanged: return Color(red: 41 / 255, green: 99 / 255, blue: 155 / 255)
|
||||||
|
case .duty: return Color(red: 0, green: 121 / 255, blue: 107 / 255)
|
||||||
|
case .event:
|
||||||
|
return customColor != nil
|
||||||
|
? Color(red: 1, green: 160 / 255, blue: 0)
|
||||||
|
: Color(red: 46 / 255, green: 125 / 255, blue: 50 / 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,36 +20,44 @@ struct TimetableWeekView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func content(data: WidgetTimetableData) -> some View {
|
private func content(data: WidgetTimetableData) -> some View {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let days = (0..<5).map { offset in
|
||||||
|
cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
|
||||||
|
}
|
||||||
|
let weekLessons = data.lessons.filter { lesson in
|
||||||
|
days.contains { cal.isDate(lesson.start, inSameDayAs: $0) }
|
||||||
|
}
|
||||||
|
let periods = trimTrailingPeriods(data.periods, lessons: weekLessons)
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
header(data: data)
|
header(data: data)
|
||||||
dayHeaderRow(data: data)
|
dayHeaderRow(days: days)
|
||||||
|
.padding(.top, GRID_MARGIN_TOP)
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
|
let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
|
||||||
let hourHeight = max(
|
|
||||||
MIN_HOUR_HEIGHT,
|
|
||||||
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
|
|
||||||
)
|
|
||||||
let dayColumnWidth = (geo.size.width - 28 - 4) / 5
|
|
||||||
// 45pt is the minimum where a 7pt-font room (e.g. "F2.04")
|
|
||||||
// plus the subject fits without `minimumScaleFactor`
|
|
||||||
// shrinking the subject into illegibility.
|
|
||||||
let subjectOnly = dayColumnWidth < 45
|
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
timeLabelsColumn(hourHeight: hourHeight, periods: data.periods)
|
PeriodRuler(periods: periods, hourHeight: height)
|
||||||
.frame(width: 28, alignment: .topTrailing)
|
|
||||||
columnDivider
|
columnDivider
|
||||||
ForEach(0..<5, id: \.self) { offset in
|
ForEach(days.indices, id: \.self) { index in
|
||||||
column(
|
TimeGridView(
|
||||||
data: data,
|
lessons: weekLessons.filter {
|
||||||
offset: offset,
|
cal.isDate($0.start, inSameDayAs: days[index])
|
||||||
hourHeight: hourHeight,
|
},
|
||||||
subjectOnly: subjectOnly
|
periods: periods,
|
||||||
|
hourHeight: height,
|
||||||
|
holiday: holiday(on: days[index], data: data),
|
||||||
|
isToday: cal.isDate(days[index], inSameDayAs: entry.date)
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity)
|
if index < 4 { columnDivider }
|
||||||
if offset < 4 { columnDivider }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func holiday(on day: Date, data: WidgetTimetableData) -> WidgetDayInfo? {
|
||||||
|
data.days?.first {
|
||||||
|
$0.isHoliday && Calendar.current.isDate($0.date, inSameDayAs: day)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,112 +71,55 @@ struct TimetableWeekView: View {
|
|||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
let week = cal.component(.weekOfYear, from: data.anchorDate)
|
let week = cal.component(.weekOfYear, from: data.anchorDate)
|
||||||
let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate
|
let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate
|
||||||
return HStack {
|
return HStack(spacing: 6) {
|
||||||
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
|
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.8)
|
.minimumScaleFactor(0.65)
|
||||||
Spacer()
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.fixedSize()
|
||||||
}
|
}
|
||||||
|
.frame(height: TITLE_ROW_HEIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dayHeaderRow(data: WidgetTimetableData) -> some View {
|
/// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
|
||||||
let cal = Calendar.current
|
/// today inside an accent circle.
|
||||||
// `.frame(maxWidth: .infinity)` is critical: without an explicit
|
private func dayHeaderRow(days: [Date]) -> some View {
|
||||||
// greedy width (or a true `Spacer()` inside), this HStack collapses
|
|
||||||
// to the sum of its fixed-width children in a `.leading` VStack
|
|
||||||
// and the 5 day-columns end up sharing ~5pt of width — the labels
|
|
||||||
// crunch to the left and stop aligning with the grid columns below.
|
|
||||||
// The fixed height keeps `columnDivider`'s vertically-flexible
|
// The fixed height keeps `columnDivider`'s vertically-flexible
|
||||||
// Rectangle from stealing space from the GeometryReader.
|
// Rectangle from stealing space from the GeometryReader below.
|
||||||
return HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Spacer().frame(width: 28)
|
Spacer().frame(width: RULER_WIDTH)
|
||||||
columnDivider
|
columnDivider
|
||||||
ForEach(0..<5, id: \.self) { offset in
|
ForEach(days.indices, id: \.self) { index in
|
||||||
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
|
dayHeaderCell(days[index])
|
||||||
VStack(spacing: -1) {
|
.frame(maxWidth: .infinity)
|
||||||
Text(weekday(for: day))
|
if index < 4 { columnDivider }
|
||||||
.font(.system(size: 10, weight: .bold))
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
Text(shortDate(day))
|
|
||||||
.font(.system(size: 8))
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
if offset < 4 { columnDivider }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 20pt fits 10pt weekday + 8pt date with tight -1pt spacing.
|
.frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT)
|
||||||
.frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View {
|
private func dayHeaderCell(_ day: Date) -> some View {
|
||||||
let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
|
// `entry.date`, not `Date()` — WidgetKit archives entries ahead of
|
||||||
let totalHeight = CGFloat(totalMin) * hourHeight / 60.0
|
// time, the midnight entry has to mark its own day.
|
||||||
// Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked
|
let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date)
|
||||||
// time+name no longer fits in one period slot and the two labels
|
return VStack(spacing: 1) {
|
||||||
// visually clip into the next slot. Drop to a single bold period
|
Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased())
|
||||||
// number until there's room to read both lines.
|
.font(.system(size: 9, weight: .medium))
|
||||||
let compact = hourHeight < 26
|
.foregroundStyle(isToday ? palette.accent : palette.textSecondary)
|
||||||
return ZStack(alignment: .topTrailing) {
|
.lineLimit(1)
|
||||||
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
Text("\(Calendar.current.component(.day, from: day))")
|
||||||
Rectangle()
|
.font(.system(size: 11, weight: isToday ? .bold : .regular))
|
||||||
.fill(palette.divider)
|
.foregroundStyle(isToday ? palette.onAccent : palette.textPrimary)
|
||||||
.frame(height: 1)
|
.lineLimit(1)
|
||||||
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
|
.frame(width: 18, height: 18)
|
||||||
}
|
.background(Circle().fill(isToday ? palette.accent : Color.clear))
|
||||||
ForEach(periods, id: \.startMinutes) { period in
|
|
||||||
VStack(alignment: .trailing, spacing: -2) {
|
|
||||||
if compact {
|
|
||||||
Text("\(period.name).")
|
|
||||||
.font(.system(size: 9, weight: .bold))
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
.lineLimit(1)
|
|
||||||
} else {
|
|
||||||
Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60))
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
.lineLimit(1)
|
|
||||||
Text("\(period.name).")
|
|
||||||
.font(.system(size: 7, weight: .bold))
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.trailing, 4)
|
|
||||||
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(height: totalHeight, alignment: .topTrailing)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func column(
|
|
||||||
data: WidgetTimetableData,
|
|
||||||
offset: Int,
|
|
||||||
hourHeight: CGFloat,
|
|
||||||
subjectOnly: Bool
|
|
||||||
) -> some View {
|
|
||||||
let cal = Calendar.current
|
|
||||||
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
|
|
||||||
let lessonsForDay = data.lessons.filter { cal.isDate($0.start, inSameDayAs: day) }
|
|
||||||
return TimeGridView(
|
|
||||||
lessons: lessonsForDay,
|
|
||||||
periods: data.periods,
|
|
||||||
hourHeight: hourHeight,
|
|
||||||
showRoom: !subjectOnly,
|
|
||||||
showTeacher: !subjectOnly,
|
|
||||||
showTimeLabels: false,
|
|
||||||
horizontalPadding: 3
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func weekday(for date: Date) -> String {
|
|
||||||
WidgetDateFormatters.weekdayShort.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func placeholder(_ message: String) -> some View {
|
private func placeholder(_ message: String) -> some View {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ struct WidgetLesson: Codable {
|
|||||||
let originalTeacher: String?
|
let originalTeacher: String?
|
||||||
let status: WidgetLessonStatus
|
let status: WidgetLessonStatus
|
||||||
let customColor: String?
|
let customColor: String?
|
||||||
|
// Tile colour resolved in Dart as "#RRGGBB" (subject colours, palettes);
|
||||||
|
// nil only for payloads written before the field existed.
|
||||||
|
let color: String?
|
||||||
let siblingCount: Int?
|
let siblingCount: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,27 @@ struct WidgetPalette {
|
|||||||
let textPrimary: Color
|
let textPrimary: Color
|
||||||
let textSecondary: Color
|
let textSecondary: Color
|
||||||
let divider: Color
|
let divider: Color
|
||||||
|
let accent: Color
|
||||||
|
let onAccent: Color
|
||||||
let breakBlock: Color
|
let breakBlock: Color
|
||||||
|
let todayTint: Color
|
||||||
|
let holidayTint: Color
|
||||||
let watermarkOpacity: Double
|
let watermarkOpacity: Double
|
||||||
|
|
||||||
|
// ColorScheme.fromSeed(#993333).primary of the app's light / dark theme.
|
||||||
|
private static let lightAccent = Color(red: 0x90 / 255, green: 0x4A / 255, blue: 0x47 / 255)
|
||||||
|
private static let darkAccent = Color(red: 0xFF / 255, green: 0xB3 / 255, blue: 0xAF / 255)
|
||||||
|
|
||||||
static let light = WidgetPalette(
|
static let light = WidgetPalette(
|
||||||
background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255),
|
background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255),
|
||||||
textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255),
|
textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255),
|
||||||
textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255),
|
textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255),
|
||||||
divider: Color.black.opacity(0x22 / 255.0),
|
divider: Color.black.opacity(0x22 / 255.0),
|
||||||
breakBlock: Color.black.opacity(0x0C / 255.0),
|
accent: lightAccent,
|
||||||
|
onAccent: .white,
|
||||||
|
breakBlock: lightAccent.opacity(0x32 / 255.0),
|
||||||
|
todayTint: lightAccent.opacity(0x0E / 255.0),
|
||||||
|
holidayTint: Color.black.opacity(0x32 / 255.0),
|
||||||
watermarkOpacity: 0.014
|
watermarkOpacity: 0.014
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,7 +38,11 @@ struct WidgetPalette {
|
|||||||
textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255),
|
textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255),
|
||||||
textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255),
|
textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255),
|
||||||
divider: Color.white.opacity(0x33 / 255.0),
|
divider: Color.white.opacity(0x33 / 255.0),
|
||||||
breakBlock: Color.white.opacity(0x14 / 255.0),
|
accent: darkAccent,
|
||||||
|
onAccent: Color(red: 0x57 / 255, green: 0x1D / 255, blue: 0x1D / 255),
|
||||||
|
breakBlock: darkAccent.opacity(0x32 / 255.0),
|
||||||
|
todayTint: darkAccent.opacity(0x0E / 255.0),
|
||||||
|
holidayTint: Color.white.opacity(0x32 / 255.0),
|
||||||
watermarkOpacity: 0.025
|
watermarkOpacity: 0.025
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../session/session_manager.dart';
|
||||||
|
import '../utils/directory_size.dart';
|
||||||
|
|
||||||
|
/// One cached response: the raw JSON payload and when it was stored.
|
||||||
|
class CacheEntry {
|
||||||
|
final String json;
|
||||||
|
final int lastUpdate;
|
||||||
|
|
||||||
|
const CacheEntry({required this.json, required this.lastUpdate});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File-per-key store behind [RequestCache] and friends.
|
||||||
|
///
|
||||||
|
/// Replaces `localstore` for response caching: localstore reads and decodes
|
||||||
|
/// the entire collection synchronously on first access, keeps every document
|
||||||
|
/// in memory for the whole session and JSON-encodes payloads a second time.
|
||||||
|
/// Here every access is async and touches exactly one file, and the payload is
|
||||||
|
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
|
||||||
|
///
|
||||||
|
/// Every account has its own directory; all access goes to the one of the
|
||||||
|
/// active account, in every isolate.
|
||||||
|
class CacheStore {
|
||||||
|
CacheStore._();
|
||||||
|
|
||||||
|
static final CacheStore instance = CacheStore._();
|
||||||
|
|
||||||
|
/// Payloads above this size are decoded on a background isolate.
|
||||||
|
static const int isolateDecodeThreshold = 64 * 1024;
|
||||||
|
|
||||||
|
static const _directoryPrefix = 'request_cache';
|
||||||
|
|
||||||
|
final Map<String, Future<Directory>> _dirs = {};
|
||||||
|
final Map<String, Future<void>> _writes = {};
|
||||||
|
|
||||||
|
/// Directory name for an account's storage [namespace]; null while no
|
||||||
|
/// account is active. The account from before multi-account support
|
||||||
|
/// (namespace '') keeps the original directory.
|
||||||
|
@visibleForTesting
|
||||||
|
static String directoryName(String? namespace) => switch (namespace) {
|
||||||
|
null => '$_directoryPrefix-signed-out',
|
||||||
|
'' => _directoryPrefix,
|
||||||
|
_ => '$_directoryPrefix-$namespace',
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<Directory> _directory() {
|
||||||
|
final name = directoryName(SessionManager().activeAccount?.namespace);
|
||||||
|
return _dirs[name] ??= () async {
|
||||||
|
final base = await getApplicationCacheDirectory();
|
||||||
|
final dir = Directory('${base.path}/$name');
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
return dir;
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> _file(String key) async =>
|
||||||
|
File('${(await _directory()).path}/${_safeName(key)}');
|
||||||
|
|
||||||
|
/// Keys are internal ids (`nc-chat-<token>`, `wd-folder-<md5>`); only path
|
||||||
|
/// separators would be a problem in a file name.
|
||||||
|
static String _safeName(String key) => key.replaceAll(RegExp(r'[/\\]'), '_');
|
||||||
|
|
||||||
|
Future<CacheEntry?> read(String key) async {
|
||||||
|
try {
|
||||||
|
final file = await _file(key);
|
||||||
|
await _writes[file.path];
|
||||||
|
return parse(await file.readAsString());
|
||||||
|
} on Object {
|
||||||
|
// Missing (PathNotFoundException) or unreadable: a cache miss.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All entries whose key starts with [prefix], read concurrently.
|
||||||
|
Future<Map<String, CacheEntry>> readAll({String prefix = ''}) async {
|
||||||
|
final keys = await this.keys(prefix: prefix);
|
||||||
|
final entries = await Future.wait(keys.map(read));
|
||||||
|
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(String key, String json) async {
|
||||||
|
// Resolved up front: a queued write must not follow an account switch
|
||||||
|
// into the next account's directory.
|
||||||
|
final file = await _file(key);
|
||||||
|
final path = file.path;
|
||||||
|
final previous = _writes[path] ?? Future<void>.value();
|
||||||
|
late final Future<void> current;
|
||||||
|
current = previous.then((_) => _write(file, json)).whenComplete(() {
|
||||||
|
if (identical(_writes[path], current)) _writes.remove(path);
|
||||||
|
});
|
||||||
|
return _writes[path] = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _write(File file, String json) async {
|
||||||
|
try {
|
||||||
|
// Write-then-rename so a reader (or the widget background isolate)
|
||||||
|
// never sees a half-written file.
|
||||||
|
final tmp = File('${file.path}.tmp');
|
||||||
|
await tmp.writeAsString(
|
||||||
|
'${DateTime.now().millisecondsSinceEpoch}\n$json',
|
||||||
|
flush: true,
|
||||||
|
);
|
||||||
|
await tmp.rename(file.path);
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('CacheStore.write(${file.path}) failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(String key) async {
|
||||||
|
try {
|
||||||
|
final file = await _file(key);
|
||||||
|
await _writes[file.path];
|
||||||
|
await file.delete();
|
||||||
|
} on Object {
|
||||||
|
// A missing or locked file is as good as deleted for a cache.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empties the active account's cache.
|
||||||
|
Future<void> clear() async {
|
||||||
|
try {
|
||||||
|
final dir = await _directory();
|
||||||
|
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('CacheStore.clear failed: $e');
|
||||||
|
}
|
||||||
|
_dirs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the cache of the account with [namespace], active or not.
|
||||||
|
Future<void> deleteNamespace(String namespace) async {
|
||||||
|
final name = directoryName(namespace);
|
||||||
|
try {
|
||||||
|
final base = await getApplicationCacheDirectory();
|
||||||
|
final dir = Directory('${base.path}/$name');
|
||||||
|
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('CacheStore.deleteNamespace failed: $e');
|
||||||
|
}
|
||||||
|
_dirs.removeWhere((key, _) => key == name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All stored keys, optionally filtered by [prefix].
|
||||||
|
Future<List<String>> keys({String prefix = ''}) async {
|
||||||
|
try {
|
||||||
|
final dir = await _directory();
|
||||||
|
return [
|
||||||
|
await for (final entity in dir.list())
|
||||||
|
if (entity is File && !entity.path.endsWith('.tmp'))
|
||||||
|
if (entity.uri.pathSegments.last case final name
|
||||||
|
when name.startsWith(prefix))
|
||||||
|
name,
|
||||||
|
];
|
||||||
|
} on Object {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes entries not written for [maxAge] from every account's cache,
|
||||||
|
/// judged by file mtime so nothing has to be read or decoded.
|
||||||
|
Future<void> deleteOlderThan(Duration maxAge) async {
|
||||||
|
try {
|
||||||
|
final base = await getApplicationCacheDirectory();
|
||||||
|
final cutoff = DateTime.now().subtract(maxAge);
|
||||||
|
await for (final dir in base.list()) {
|
||||||
|
if (dir is! Directory) continue;
|
||||||
|
final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
|
||||||
|
if (!name.startsWith(_directoryPrefix)) continue;
|
||||||
|
await for (final entity in dir.list()) {
|
||||||
|
if (entity is! File) continue;
|
||||||
|
// ignore: avoid_slow_async_io
|
||||||
|
final modified = (await entity.stat()).modified;
|
||||||
|
if (modified.isBefore(cutoff)) await entity.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('CacheStore.deleteOlderThan failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total size of all entries in bytes.
|
||||||
|
Future<int> totalSize() async => directorySize(await _directory());
|
||||||
|
|
||||||
|
/// Parses the stored file format; public for tests.
|
||||||
|
static CacheEntry? parse(String raw) {
|
||||||
|
final newline = raw.indexOf('\n');
|
||||||
|
if (newline < 0) return null;
|
||||||
|
final lastUpdate = int.tryParse(raw.substring(0, newline));
|
||||||
|
if (lastUpdate == null) return null;
|
||||||
|
return CacheEntry(json: raw.substring(newline + 1), lastUpdate: lastUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a cached payload, off the UI isolate when it is large.
|
||||||
|
static Future<Map<String, dynamic>> decode(String json) =>
|
||||||
|
json.length > isolateDecodeThreshold
|
||||||
|
? compute(_decodeMap, json)
|
||||||
|
: Future.value(_decodeMap(json));
|
||||||
|
|
||||||
|
static Map<String, dynamic> _decodeMap(String json) =>
|
||||||
|
jsonDecode(json) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
/// Deletes the directory the old localstore-based cache lived in. It is
|
||||||
|
/// never read again and would otherwise linger with up to 200 days of data.
|
||||||
|
static Future<void> deleteLegacyLocalstoreCache() async {
|
||||||
|
try {
|
||||||
|
final docs = await getApplicationDocumentsDirectory();
|
||||||
|
final legacy = Directory('${docs.path}/MarianumMobile');
|
||||||
|
if (legacy.existsSync()) await legacy.delete(recursive: true);
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('Legacy cache cleanup failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import 'app_exception.dart';
|
||||||
|
|
||||||
|
/// A request finished after the account it was started for signed out. Its
|
||||||
|
/// result must not reach the state or cache of whoever is signed in now.
|
||||||
|
class StaleSessionException extends AppException {
|
||||||
|
const StaleSessionException()
|
||||||
|
: super(
|
||||||
|
userMessage: 'Die Anmeldung hat sich geändert. Bitte lade neu.',
|
||||||
|
technicalDetails: 'result of a previous session discarded',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
|
|||||||
|
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
import '../../errors/server_exception.dart';
|
import '../../errors/server_exception.dart';
|
||||||
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
import 'autocomplete_response.dart';
|
import 'autocomplete_response.dart';
|
||||||
|
|
||||||
@@ -30,7 +31,12 @@ class AutocompleteApi {
|
|||||||
'limit': '10',
|
'limit': '10',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final response = await http.get(uri, headers: NextcloudOcs.headers());
|
final response = (await sendGuarded(
|
||||||
|
'Autocomplete $uri',
|
||||||
|
() => http
|
||||||
|
.get(uri, headers: NextcloudOcs.headers())
|
||||||
|
.timeout(const Duration(seconds: 20)),
|
||||||
|
))!;
|
||||||
if (response.statusCode != HttpStatus.ok) {
|
if (response.statusCode != HttpStatus.ok) {
|
||||||
throw ServerException(
|
throw ServerException(
|
||||||
statusCode: response.statusCode,
|
statusCode: response.statusCode,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
|
|||||||
|
|
||||||
import '../../../api_params.dart';
|
import '../../../api_params.dart';
|
||||||
import '../../../api_response.dart';
|
import '../../../api_response.dart';
|
||||||
|
import '../room/get_room_response.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
|
|
||||||
/// Small POST/DELETE-only Talk endpoints that have no response payload.
|
/// Small POST/DELETE-only Talk endpoints that have no response payload.
|
||||||
@@ -105,3 +106,21 @@ class DeleteRoomAvatar extends TalkApi {
|
|||||||
Map<String, String>? headers,
|
Map<String, String>? headers,
|
||||||
) => http.delete(uri, headers: headers);
|
) => http.delete(uri, headers: headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SetNotificationLevel extends TalkApi {
|
||||||
|
final String chatToken;
|
||||||
|
final GetRoomResponseObjectParticipantNotificationLevel level;
|
||||||
|
|
||||||
|
SetNotificationLevel(this.chatToken, this.level)
|
||||||
|
: super('v4/room/$chatToken/notify', null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ApiResponse? assemble(String raw) => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.Response> request(
|
||||||
|
Uri uri,
|
||||||
|
ApiParams? body,
|
||||||
|
Map<String, String>? headers,
|
||||||
|
) => http.post(uri, headers: headers, body: {'level': '${level.apiValue}'});
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
|
|||||||
lookIntoFuture: GetChatParamsSwitch.off,
|
lookIntoFuture: GetChatParamsSwitch.off,
|
||||||
setReadMarker: GetChatParamsSwitch.on,
|
setReadMarker: GetChatParamsSwitch.on,
|
||||||
// Small initial page; also the per-chat offline snapshot written to
|
// Small initial page; also the per-chat offline snapshot written to
|
||||||
// localstore. Older messages are paged in on scroll-up via
|
// the request cache. Older messages are paged in on scroll-up via
|
||||||
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
|
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
|
||||||
limit: 50,
|
limit: 50,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../errors/server_exception.dart';
|
import '../../../errors/server_exception.dart';
|
||||||
@@ -45,8 +46,10 @@ class GetChatHistory {
|
|||||||
final status = response.statusCode;
|
final status = response.statusCode;
|
||||||
if (status == 304) return null;
|
if (status == 304) return null;
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
|
// A page holds up to 200 messages and lands while the user is scrolling;
|
||||||
..headers = response.headers;
|
// decoding it on the UI isolate stalls the fling.
|
||||||
|
final parsed = await compute(_parseChatResponse, response.body);
|
||||||
|
return parsed..headers = response.headers;
|
||||||
}
|
}
|
||||||
throw ServerException(
|
throw ServerException(
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
@@ -54,3 +57,6 @@ class GetChatHistory {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GetChatResponse _parseChatResponse(String body) =>
|
||||||
|
GetChatResponse.fromJson(NextcloudOcs.decode(body));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../nextcloud_ocs.dart';
|
import '../../nextcloud_ocs.dart';
|
||||||
@@ -10,8 +11,12 @@ class GetRoom extends TalkApi<GetRoomResponse> {
|
|||||||
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
|
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
GetRoomResponse assemble(String raw) =>
|
GetRoomResponse assemble(String raw) => _parseRooms(raw);
|
||||||
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
|
|
||||||
|
// The room list with status is 100–250 KB and polled every 15–60 s.
|
||||||
|
@override
|
||||||
|
Future<GetRoomResponse> assembleAsync(String raw) =>
|
||||||
|
compute(_parseRooms, raw);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<http.Response> request(
|
Future<http.Response> request(
|
||||||
@@ -20,3 +25,6 @@ class GetRoom extends TalkApi<GetRoomResponse> {
|
|||||||
Map<String, String>? headers,
|
Map<String, String>? headers,
|
||||||
) => http.get(uri, headers: headers);
|
) => http.get(uri, headers: headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GetRoomResponse _parseRooms(String raw) =>
|
||||||
|
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ enum GetRoomResponseObjectParticipantNotificationLevel {
|
|||||||
neverNotify,
|
neverNotify,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension NotificationLevelApiValue
|
||||||
|
on GetRoomResponseObjectParticipantNotificationLevel {
|
||||||
|
int get apiValue =>
|
||||||
|
_$GetRoomResponseObjectParticipantNotificationLevelEnumMap[this]!;
|
||||||
|
}
|
||||||
|
|
||||||
enum GetRoomResponseObjectMessageActorType {
|
enum GetRoomResponseObjectMessageActorType {
|
||||||
@JsonValue('deleted_users')
|
@JsonValue('deleted_users')
|
||||||
deletedUsers,
|
deletedUsers,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'get_room_response.dart';
|
||||||
|
|
||||||
|
typedef NotificationLevel = GetRoomResponseObjectParticipantNotificationLevel;
|
||||||
|
|
||||||
|
/// The levels a user can pick in the UI; [NotificationLevel.defaultLevel]
|
||||||
|
/// is only ever reported by the server, never chosen.
|
||||||
|
const selectableNotificationLevels = [
|
||||||
|
NotificationLevel.alwaysNotify,
|
||||||
|
NotificationLevel.notifyOnMention,
|
||||||
|
NotificationLevel.neverNotify,
|
||||||
|
];
|
||||||
|
|
||||||
|
extension RoomNotificationLevel on GetRoomResponseObject {
|
||||||
|
/// Resolves "default" the way Spreed does: 1:1 chats notify on every
|
||||||
|
/// message, group chats only on mentions (`default_group_notification`).
|
||||||
|
NotificationLevel get effectiveNotificationLevel {
|
||||||
|
if (notificationLevel != NotificationLevel.defaultLevel) {
|
||||||
|
return notificationLevel;
|
||||||
|
}
|
||||||
|
return type == GetRoomResponseObjectConversationType.oneToOne
|
||||||
|
? NotificationLevel.alwaysNotify
|
||||||
|
: NotificationLevel.notifyOnMention;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isMuted =>
|
||||||
|
effectiveNotificationLevel == NotificationLevel.neverNotify;
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ Future<void>? _shareFolderReady;
|
|||||||
Future<void> ensureTalkShareFolder() =>
|
Future<void> ensureTalkShareFolder() =>
|
||||||
_shareFolderReady ??= _createTalkShareFolder();
|
_shareFolderReady ??= _createTalkShareFolder();
|
||||||
|
|
||||||
|
/// The folder is per user; after a sign-out the next account has to check
|
||||||
|
/// again.
|
||||||
|
void resetTalkShareFolderCache() => _shareFolderReady = null;
|
||||||
|
|
||||||
Future<void> _createTalkShareFolder() async {
|
Future<void> _createTalkShareFolder() async {
|
||||||
try {
|
try {
|
||||||
final webdav = await WebdavApi.webdav;
|
final webdav = await WebdavApi.webdav;
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import '../nextcloud_ocs.dart';
|
|||||||
import 'talk_error.dart';
|
import 'talk_error.dart';
|
||||||
|
|
||||||
abstract class TalkApi<T extends ApiResponse?> {
|
abstract class TalkApi<T extends ApiResponse?> {
|
||||||
|
// package:http has no timeout of its own; a request stuck on a dead
|
||||||
|
// connection (e.g. one started right before a suspend) would spin forever.
|
||||||
|
static const Duration _timeout = Duration(seconds: 30);
|
||||||
|
|
||||||
String path;
|
String path;
|
||||||
ApiParams? body;
|
ApiParams? body;
|
||||||
Map<String, String>? headers;
|
Map<String, String>? headers;
|
||||||
@@ -26,6 +30,10 @@ abstract class TalkApi<T extends ApiResponse?> {
|
|||||||
);
|
);
|
||||||
T assemble(String raw);
|
T assemble(String raw);
|
||||||
|
|
||||||
|
/// Override to parse large payloads off the UI isolate (e.g. via `compute`
|
||||||
|
/// with a top-level parser); defaults to the synchronous [assemble].
|
||||||
|
Future<T> assembleAsync(String raw) async => assemble(raw);
|
||||||
|
|
||||||
Future<T> run() async {
|
Future<T> run() async {
|
||||||
final endpoint = NextcloudOcs.uri(
|
final endpoint = NextcloudOcs.uri(
|
||||||
'apps/spreed/api/$path',
|
'apps/spreed/api/$path',
|
||||||
@@ -35,7 +43,7 @@ abstract class TalkApi<T extends ApiResponse?> {
|
|||||||
|
|
||||||
final data = await sendGuarded(
|
final data = await sendGuarded(
|
||||||
'Talk $endpoint',
|
'Talk $endpoint',
|
||||||
() => request(endpoint, body, mergedHeaders),
|
() => request(endpoint, body, mergedHeaders)?.timeout(_timeout),
|
||||||
);
|
);
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
throw const NetworkException(
|
throw const NetworkException(
|
||||||
@@ -65,7 +73,7 @@ abstract class TalkApi<T extends ApiResponse?> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final assembled = assemble(data.body);
|
final assembled = await assembleAsync(data.body);
|
||||||
assembled?.headers = data.headers;
|
assembled?.headers = data.headers;
|
||||||
return assembled;
|
return assembled;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ class CacheableFile {
|
|||||||
/// file/folder in the list. Nullable so older cached entries decode fine.
|
/// file/folder in the list. Nullable so older cached entries decode fine.
|
||||||
bool? isSharedWithMe;
|
bool? isSharedWithMe;
|
||||||
|
|
||||||
|
/// Lower-cased [name] for sorting/filtering, cached because comparators
|
||||||
|
/// call it for every comparison (name is mutable, hence the identity check).
|
||||||
|
String get lowerName {
|
||||||
|
if (!identical(_lowerNameFor, name)) {
|
||||||
|
_lowerNameFor = name;
|
||||||
|
_lowerName = name.toLowerCase();
|
||||||
|
}
|
||||||
|
return _lowerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _lowerNameFor;
|
||||||
|
String _lowerName = '';
|
||||||
|
|
||||||
CacheableFile({
|
CacheableFile({
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.isDirectory,
|
required this.isDirectory,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:localstore/localstore.dart';
|
|
||||||
|
|
||||||
import '../../../../../utils/cache_invalidation_bus.dart';
|
import '../../../../../utils/cache_invalidation_bus.dart';
|
||||||
|
import '../../../../cache_store.dart';
|
||||||
import '../../../../request_cache.dart';
|
import '../../../../request_cache.dart';
|
||||||
import 'list_files.dart';
|
import 'list_files.dart';
|
||||||
import 'list_files_params.dart';
|
import 'list_files_params.dart';
|
||||||
@@ -43,14 +43,11 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
|
|||||||
/// (slow) root listing is already populated by the time the user
|
/// (slow) root listing is already populated by the time the user
|
||||||
/// navigates to the Files module.
|
/// navigates to the Files module.
|
||||||
///
|
///
|
||||||
/// No-ops when a cached root payload is already present in localstore —
|
/// No-ops when a cached root payload is already present in the cache —
|
||||||
/// the regular TTL handling in [RequestCache] takes over from there.
|
/// the regular TTL handling in [RequestCache] takes over from there.
|
||||||
static Future<void> prefetchRootListing() async {
|
static Future<void> prefetchRootListing() async {
|
||||||
const rootPath = '';
|
const rootPath = '';
|
||||||
final cached = await Localstore.instance
|
final cached = await CacheStore.instance.read(_documentId(rootPath));
|
||||||
.collection(RequestCache.collection)
|
|
||||||
.doc(_documentId(rootPath))
|
|
||||||
.get();
|
|
||||||
if (cached != null) return;
|
if (cached != null) return;
|
||||||
// Drive the same code path as a regular fetch so the result lands in
|
// Drive the same code path as a regular fetch so the result lands in
|
||||||
// the cache; we don't care about the in-memory callback here.
|
// the cache; we don't care about the in-memory callback here.
|
||||||
@@ -69,10 +66,7 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
|
|||||||
/// `_FilesView` for that path via [CacheInvalidationBus] so it refetches
|
/// `_FilesView` for that path via [CacheInvalidationBus] so it refetches
|
||||||
/// even while it is sitting in the background of the navigation stack.
|
/// even while it is sitting in the background of the navigation stack.
|
||||||
static Future<void> invalidate(String path) async {
|
static Future<void> invalidate(String path) async {
|
||||||
await Localstore.instance
|
await CacheStore.instance.delete(_documentId(path));
|
||||||
.collection(RequestCache.collection)
|
|
||||||
.doc(_documentId(path))
|
|
||||||
.delete();
|
|
||||||
CacheInvalidationBus.notifyListFiles(path);
|
CacheInvalidationBus.notifyListFiles(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ abstract class WebdavApi<T> {
|
|||||||
/// changes (app password minted/renewed, account switch) so it never keeps
|
/// changes (app password minted/renewed, account switch) so it never keeps
|
||||||
/// authenticating with stale credentials.
|
/// authenticating with stale credentials.
|
||||||
static Future<WebDavClient> get webdav {
|
static Future<WebDavClient> get webdav {
|
||||||
|
// Keyed by user too: two accounts may share a password (no app password
|
||||||
|
// minted), and the client would keep the previous login name.
|
||||||
final nextcloud = SessionManager().requireNextcloud();
|
final nextcloud = SessionManager().requireNextcloud();
|
||||||
final secret = '${nextcloud.username}:${nextcloud.secret}';
|
final secret = '${nextcloud.username}:${nextcloud.secret}';
|
||||||
if (_webdav == null || _webdavSecret != secret) {
|
if (_webdav == null || _webdavSecret != secret) {
|
||||||
|
|||||||
@@ -105,17 +105,29 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
|||||||
Future<bool> _performReLogin() async {
|
Future<bool> _performReLogin() async {
|
||||||
final session = SessionManager().current;
|
final session = SessionManager().current;
|
||||||
if (session is! CredentialSession) return false;
|
if (session is! CredentialSession) return false;
|
||||||
|
final username = session.username;
|
||||||
|
// A background engine (widget task) keeps the account it loaded. When
|
||||||
|
// the app signed that account out, its revoked token answers 401 — a
|
||||||
|
// re-login would mint a fresh token for it into the shared keystore.
|
||||||
|
if (await SessionManager().readStoredUsername() != username) return false;
|
||||||
try {
|
try {
|
||||||
await _loginClient.run(
|
await _loginClient.run(
|
||||||
username: session.username,
|
username: username,
|
||||||
password: session.password,
|
password: session.password,
|
||||||
tokenName: await DeviceTokenName.resolve(),
|
tokenName: await DeviceTokenName.resolve(),
|
||||||
);
|
);
|
||||||
return true;
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
await _tokenStorage.clear();
|
if (await SessionManager().readStoredUsername() == username) {
|
||||||
|
await _tokenStorage.clear();
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
final stored = await SessionManager().readStoredUsername();
|
||||||
|
if (stored == username) return true;
|
||||||
|
// Signed out during the login: drop the orphaned token, unless another
|
||||||
|
// account already stored its own.
|
||||||
|
if (stored == null) await _tokenStorage.clear();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response<dynamic>> _retryWithFreshToken(
|
Future<Response<dynamic>> _retryWithFreshToken(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class SessionValidator {
|
|||||||
// The probes use their own dio (bypassing the demo interceptor), so a demo
|
// The probes use their own dio (bypassing the demo interceptor), so a demo
|
||||||
// session must be skipped or its missing token would 401 into a logout.
|
// session must be skipped or its missing token would 401 into a logout.
|
||||||
if (session == null || session.isDemo) return;
|
if (session == null || session.isDemo) return;
|
||||||
|
final epoch = SessionManager().sessionEpoch;
|
||||||
try {
|
try {
|
||||||
switch (session) {
|
switch (session) {
|
||||||
case CredentialSession(:final username, :final password):
|
case CredentialSession(:final username, :final password):
|
||||||
@@ -28,6 +29,9 @@ class SessionValidator {
|
|||||||
}
|
}
|
||||||
} on AuthException catch (e) {
|
} on AuthException catch (e) {
|
||||||
if (e.statusCode != 401) return;
|
if (e.statusCode != 401) return;
|
||||||
|
// The probed account is no longer the active one; the 401 must not
|
||||||
|
// sign out whoever took over meanwhile.
|
||||||
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
log('MC: stored session rejected — forcing re-login');
|
log('MC: stored session rejected — forcing re-login');
|
||||||
final next = await SessionLifecycle.signOut(
|
final next = await SessionLifecycle.signOut(
|
||||||
notice: switch (session) {
|
notice: switch (session) {
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ class MarianumConnectApi {
|
|||||||
|
|
||||||
static Dio dio() => _instance;
|
static Dio dio() => _instance;
|
||||||
|
|
||||||
|
/// Drops the pooled keep-alive sockets. After a suspend the OS or a NAT
|
||||||
|
/// has usually killed them already, but dart:io only notices once a request
|
||||||
|
/// is written into one — which then hangs until [_receiveTimeout].
|
||||||
|
/// In-flight requests finish on the old adapter.
|
||||||
|
static void resetConnections() {
|
||||||
|
final stale = _instance.httpClientAdapter;
|
||||||
|
_instance.httpClientAdapter = HttpClientAdapter();
|
||||||
|
stale.close();
|
||||||
|
}
|
||||||
|
|
||||||
/// A fresh dio with the standard JSON options but no interceptors — used by
|
/// A fresh dio with the standard JSON options but no interceptors — used by
|
||||||
/// the auth queries (login/verify) that must bypass the bearer/demo
|
/// the auth queries (login/verify) that must bypass the bearer/demo
|
||||||
/// interceptors to avoid a re-auth loop.
|
/// interceptors to avoid a re-auth loop.
|
||||||
|
|||||||
@@ -3,41 +3,71 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../../../../push/push_registration.dart';
|
||||||
import '../../../../push/push_registration_store.dart';
|
import '../../../../push/push_registration_store.dart';
|
||||||
import '../../../../push/push_registration_type.dart';
|
import '../../../../push/push_registration_type.dart';
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import 'telemetry_device_id.dart';
|
import 'telemetry_device_id.dart';
|
||||||
|
|
||||||
|
typedef _PushState = ({
|
||||||
|
bool enabled,
|
||||||
|
AuthorizationStatus? permission,
|
||||||
|
String? pushDeviceIdentifier,
|
||||||
|
});
|
||||||
|
|
||||||
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
|
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
|
||||||
/// upserts the stable install id, platform, app version and device info. Sent
|
/// upserts the stable install id, platform, app version and device info. Sent
|
||||||
/// on app start, again once push registration completes that session (so a
|
/// on app start, on resume and once push registration completes, throttled to
|
||||||
/// fresh registration isn't under-reported until the next launch), and on
|
/// one per 15 minutes unless the push state changed in between.
|
||||||
/// resume after the app spent more than 15 minutes in the background.
|
|
||||||
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
|
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
|
||||||
/// mhsl.eu `server/userIndex/update` call.
|
/// mhsl.eu `server/userIndex/update` call.
|
||||||
class TelemetryHeartbeat extends MarianumConnectQuery {
|
class TelemetryHeartbeat extends MarianumConnectQuery {
|
||||||
TelemetryHeartbeat({super.dio});
|
TelemetryHeartbeat({super.dio});
|
||||||
|
|
||||||
|
static const Duration _interval = Duration(minutes: 15);
|
||||||
|
|
||||||
|
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
|
||||||
|
// reliably while the app is suspended, so the window would still be open on
|
||||||
|
// the resume it is supposed to let through.
|
||||||
|
static DateTime? _lastSentAt;
|
||||||
|
static _PushState? _lastPushState;
|
||||||
|
|
||||||
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
|
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
|
||||||
/// send never disrupts app start. Used from the app shell's initState and
|
/// send never disrupts app start. A changed push state (toggle, OS
|
||||||
/// lifecycle handler, and re-emitted once push registration completes (see
|
/// permission — e.g. back from the system settings — or a fresh
|
||||||
/// `_MainState._syncPush`).
|
/// registration) bypasses the throttle: the server picks visible vs. silent
|
||||||
|
/// delivery from it, so it must not wait for the next window.
|
||||||
static void report({required bool notificationsEnabled}) {
|
static void report({required bool notificationsEnabled}) {
|
||||||
unawaited(
|
unawaited(_report(notificationsEnabled).catchError((Object _) {}));
|
||||||
TelemetryHeartbeat()
|
|
||||||
.send(notificationsEnabled: notificationsEnabled)
|
|
||||||
.catchError((Object _) {}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> send({required bool notificationsEnabled}) => guard(() async {
|
static Future<void> _report(bool enabled) async {
|
||||||
|
final pushState = (
|
||||||
|
enabled: enabled,
|
||||||
|
permission: await PushRegistration.osPermissionStatus(),
|
||||||
|
pushDeviceIdentifier: await const PushRegistrationStore()
|
||||||
|
.deviceIdentifier(PushRegistrationType.general),
|
||||||
|
);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final last = _lastSentAt;
|
||||||
|
if (last != null &&
|
||||||
|
now.difference(last) < _interval &&
|
||||||
|
pushState == _lastPushState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Claimed before sending so overlapping triggers don't both go out.
|
||||||
|
_lastSentAt = now;
|
||||||
|
_lastPushState = pushState;
|
||||||
|
await TelemetryHeartbeat()._send(pushState);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _send(_PushState push) => guard(() async {
|
||||||
final info = DeviceInfoPlugin();
|
final info = DeviceInfoPlugin();
|
||||||
final package = await PackageInfo.fromPlatform();
|
final package = await PackageInfo.fromPlatform();
|
||||||
final deviceIdentifier = await TelemetryDeviceId.resolve();
|
final deviceIdentifier = await TelemetryDeviceId.resolve();
|
||||||
final pushDeviceIdentifier = await const PushRegistrationStore()
|
|
||||||
.deviceIdentifier(PushRegistrationType.general);
|
|
||||||
|
|
||||||
var platform = 'unknown';
|
var platform = 'unknown';
|
||||||
String? deviceModel;
|
String? deviceModel;
|
||||||
@@ -62,11 +92,12 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
|
|||||||
data: {
|
data: {
|
||||||
'deviceIdentifier': deviceIdentifier,
|
'deviceIdentifier': deviceIdentifier,
|
||||||
// `pushDeviceIdentifier` reflects a *completed* registration and is
|
// `pushDeviceIdentifier` reflects a *completed* registration and is
|
||||||
// absent until it lands; `pushEnabled` carries the user's intent
|
// absent until it lands; `pushEnabled` carries the user's intent (the
|
||||||
// (the notification toggle) so the backend can tell "user wants push"
|
// toggle). The server combines it with `osPermission` into the
|
||||||
// apart from "registration not finished yet".
|
// visible-vs-silent delivery decision.
|
||||||
'pushDeviceIdentifier': ?pushDeviceIdentifier,
|
'pushDeviceIdentifier': ?push.pushDeviceIdentifier,
|
||||||
'pushEnabled': notificationsEnabled,
|
'pushEnabled': push.enabled,
|
||||||
|
'osPermission': ?osPermissionWireValue(push.permission),
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'appVersion': package.version,
|
'appVersion': package.version,
|
||||||
'appBuild': int.tryParse(package.buildNumber),
|
'appBuild': int.tryParse(package.buildNumber),
|
||||||
@@ -77,3 +108,13 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire value of the OS notification permission for the heartbeat.
|
||||||
|
String? osPermissionWireValue(AuthorizationStatus? status) => switch (status) {
|
||||||
|
AuthorizationStatus.authorized => 'granted',
|
||||||
|
AuthorizationStatus.provisional => 'provisional',
|
||||||
|
AuthorizationStatus.denied ||
|
||||||
|
AuthorizationStatus.deniedPermanently => 'denied',
|
||||||
|
AuthorizationStatus.notDetermined => 'notDetermined',
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
|
|||||||
+13
-16
@@ -1,6 +1,6 @@
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:localstore/localstore.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../../../../session/session.dart';
|
import '../../../../session/session.dart';
|
||||||
import '../../../../session/session_manager.dart';
|
import '../../../../session/session_manager.dart';
|
||||||
@@ -21,9 +21,7 @@ import 'timetable_custom_events_add.dart';
|
|||||||
/// failure only sees the events that were not yet moved, so nothing is
|
/// failure only sees the events that were not yet moved, so nothing is
|
||||||
/// duplicated. The flag is set only once MHSL reports no remaining events.
|
/// duplicated. The flag is set only once MHSL reports no remaining events.
|
||||||
class CustomEventsMigration {
|
class CustomEventsMigration {
|
||||||
static const String _collection = 'MarianumMobile';
|
static const String _doneKey = 'customEventsMigratedToMc';
|
||||||
static const String _document = 'customEventsMigration';
|
|
||||||
static const String _doneKey = 'migratedToMc';
|
|
||||||
|
|
||||||
const CustomEventsMigration._();
|
const CustomEventsMigration._();
|
||||||
|
|
||||||
@@ -34,18 +32,23 @@ class CustomEventsMigration {
|
|||||||
if (session is! CredentialSession || session.isDemo) return;
|
if (session is! CredentialSession || session.isDemo) return;
|
||||||
if (await _isDone()) return;
|
if (await _isDone()) return;
|
||||||
|
|
||||||
|
final epoch = SessionManager().sessionEpoch;
|
||||||
try {
|
try {
|
||||||
final response = await GetCustomTimetableEvent(
|
final response = await GetCustomTimetableEvent(
|
||||||
GetCustomTimetableEventParams(session.legacyUserSecret),
|
GetCustomTimetableEventParams(session.legacyUserSecret),
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
for (final event in response.events) {
|
for (final event in response.events) {
|
||||||
|
// The POST authenticates with whoever is signed in now; after a
|
||||||
|
// sign-out the previous account's events would land in the next one.
|
||||||
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
await TimetableCustomEventsAdd().run(event);
|
await TimetableCustomEventsAdd().run(event);
|
||||||
await RemoveCustomTimetableEvent(
|
await RemoveCustomTimetableEvent(
|
||||||
RemoveCustomTimetableEventParams(event.id),
|
RemoveCustomTimetableEventParams(event.id),
|
||||||
).run();
|
).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
await _markDone();
|
await _markDone();
|
||||||
log(
|
log(
|
||||||
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
|
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
|
||||||
@@ -57,17 +60,11 @@ class CustomEventsMigration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> _isDone() async {
|
// SharedPreferences, like the old cache document, is cleared on sign-out,
|
||||||
final data = await Localstore.instance
|
// so the next account runs its own migration.
|
||||||
.collection(_collection)
|
static Future<bool> _isDone() async =>
|
||||||
.doc(_document)
|
(await SharedPreferences.getInstance()).getBool(_doneKey) ?? false;
|
||||||
.get();
|
|
||||||
return data != null && data[_doneKey] == true;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> _markDone() async {
|
static Future<void> _markDone() async =>
|
||||||
await Localstore.instance.collection(_collection).doc(_document).set({
|
(await SharedPreferences.getInstance()).setBool(_doneKey, true);
|
||||||
_doneKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-2
@@ -10,12 +10,17 @@ class McTimetableTeacher {
|
|||||||
final String displayName;
|
final String displayName;
|
||||||
final String? originalShortName;
|
final String? originalShortName;
|
||||||
final String? originalDisplayName;
|
final String? originalDisplayName;
|
||||||
|
// Webuntis element ids; null from servers that predate them.
|
||||||
|
final int? id;
|
||||||
|
final int? originalId;
|
||||||
|
|
||||||
McTimetableTeacher({
|
McTimetableTeacher({
|
||||||
required this.shortName,
|
required this.shortName,
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
this.originalShortName,
|
this.originalShortName,
|
||||||
this.originalDisplayName,
|
this.originalDisplayName,
|
||||||
|
this.id,
|
||||||
|
this.originalId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory McTimetableTeacher.fromJson(Map<String, dynamic> json) =>
|
factory McTimetableTeacher.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -45,6 +50,8 @@ class McTimetableEntry {
|
|||||||
final String? substitutionText;
|
final String? substitutionText;
|
||||||
final String? lessonText;
|
final String? lessonText;
|
||||||
final String? infoText;
|
final String? infoText;
|
||||||
|
// Index-aligned with [classNames]; null from servers that predate it.
|
||||||
|
final List<int>? classIds;
|
||||||
|
|
||||||
McTimetableEntry({
|
McTimetableEntry({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -60,6 +67,7 @@ class McTimetableEntry {
|
|||||||
required this.substitutionText,
|
required this.substitutionText,
|
||||||
required this.lessonText,
|
required this.lessonText,
|
||||||
required this.infoText,
|
required this.infoText,
|
||||||
|
this.classIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory McTimetableEntry.fromJson(Map<String, dynamic> json) =>
|
factory McTimetableEntry.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -68,10 +76,17 @@ class McTimetableEntry {
|
|||||||
|
|
||||||
/// Combines the calendar date with the hour/minute portion of [startTime]
|
/// Combines the calendar date with the hour/minute portion of [startTime]
|
||||||
/// (which carries a 1970 placeholder date) into a real DateTime.
|
/// (which carries a 1970 placeholder date) into a real DateTime.
|
||||||
DateTime get startDateTime =>
|
DateTime get startDateTime => _startDateTime;
|
||||||
|
|
||||||
|
DateTime get endDateTime => _endDateTime;
|
||||||
|
|
||||||
|
// Cached (private, so json_serializable ignores them): sorting and lesson
|
||||||
|
// merging read these per comparison, and every local DateTime construction
|
||||||
|
// does a timezone lookup.
|
||||||
|
late final DateTime _startDateTime =
|
||||||
DateTime(date.year, date.month, date.day, startTime.hour, startTime.minute);
|
DateTime(date.year, date.month, date.day, startTime.hour, startTime.minute);
|
||||||
|
|
||||||
DateTime get endDateTime =>
|
late final DateTime _endDateTime =
|
||||||
DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute);
|
DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute);
|
||||||
|
|
||||||
static DateTime _dateFromJson(String raw) => DateTime.parse(raw);
|
static DateTime _dateFromJson(String raw) => DateTime.parse(raw);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ McTimetableTeacher _$McTimetableTeacherFromJson(Map<String, dynamic> json) =>
|
|||||||
displayName: json['displayName'] as String,
|
displayName: json['displayName'] as String,
|
||||||
originalShortName: json['originalShortName'] as String?,
|
originalShortName: json['originalShortName'] as String?,
|
||||||
originalDisplayName: json['originalDisplayName'] as String?,
|
originalDisplayName: json['originalDisplayName'] as String?,
|
||||||
|
id: (json['id'] as num?)?.toInt(),
|
||||||
|
originalId: (json['originalId'] as num?)?.toInt(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
|
Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
|
||||||
@@ -20,6 +22,8 @@ Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
|
|||||||
'displayName': instance.displayName,
|
'displayName': instance.displayName,
|
||||||
'originalShortName': instance.originalShortName,
|
'originalShortName': instance.originalShortName,
|
||||||
'originalDisplayName': instance.originalDisplayName,
|
'originalDisplayName': instance.originalDisplayName,
|
||||||
|
'id': instance.id,
|
||||||
|
'originalId': instance.originalId,
|
||||||
};
|
};
|
||||||
|
|
||||||
McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
|
McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
|
||||||
@@ -43,6 +47,9 @@ McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
|
|||||||
substitutionText: json['substitutionText'] as String?,
|
substitutionText: json['substitutionText'] as String?,
|
||||||
lessonText: json['lessonText'] as String?,
|
lessonText: json['lessonText'] as String?,
|
||||||
infoText: json['infoText'] as String?,
|
infoText: json['infoText'] as String?,
|
||||||
|
classIds: (json['classIds'] as List<dynamic>?)
|
||||||
|
?.map((e) => (e as num).toInt())
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
|
Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
|
||||||
@@ -60,6 +67,7 @@ Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
|
|||||||
'substitutionText': instance.substitutionText,
|
'substitutionText': instance.substitutionText,
|
||||||
'lessonText': instance.lessonText,
|
'lessonText': instance.lessonText,
|
||||||
'infoText': instance.infoText,
|
'infoText': instance.infoText,
|
||||||
|
'classIds': instance.classIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson(
|
TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson(
|
||||||
|
|||||||
+50
-35
@@ -1,10 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:localstore/localstore.dart';
|
import '../session/session_manager.dart';
|
||||||
|
|
||||||
import 'api_response.dart';
|
import 'api_response.dart';
|
||||||
|
import 'cache_store.dart';
|
||||||
import 'errors/parse_exception.dart';
|
import 'errors/parse_exception.dart';
|
||||||
|
import 'errors/stale_session_exception.dart';
|
||||||
|
|
||||||
abstract class RequestCache<T extends ApiResponse?> {
|
abstract class RequestCache<T extends ApiResponse?> {
|
||||||
static const int cacheNothing = 0;
|
static const int cacheNothing = 0;
|
||||||
@@ -12,12 +13,10 @@ abstract class RequestCache<T extends ApiResponse?> {
|
|||||||
static const int cacheHour = 60 * 60;
|
static const int cacheHour = 60 * 60;
|
||||||
static const int cacheDay = 60 * 60 * 24;
|
static const int cacheDay = 60 * 60 * 24;
|
||||||
|
|
||||||
static String collection = 'MarianumMobile';
|
|
||||||
|
|
||||||
int maxCacheTime;
|
int maxCacheTime;
|
||||||
void Function(T)? onUpdate;
|
void Function(T)? onUpdate;
|
||||||
|
|
||||||
/// Called only when [start] finds a cached payload in localstore. Use this
|
/// Called only when [start] finds a cached payload in the [CacheStore]. Use this
|
||||||
/// (instead of [onUpdate]) when callers need to distinguish stale-but-fast
|
/// (instead of [onUpdate]) when callers need to distinguish stale-but-fast
|
||||||
/// cache hits from authoritative network responses.
|
/// cache hits from authoritative network responses.
|
||||||
void Function(T)? onCacheData;
|
void Function(T)? onCacheData;
|
||||||
@@ -48,42 +47,57 @@ abstract class RequestCache<T extends ApiResponse?> {
|
|||||||
static void ignore(Exception e) {}
|
static void ignore(Exception e) {}
|
||||||
|
|
||||||
Future<void> start(String document) async {
|
Future<void> start(String document) async {
|
||||||
|
final epoch = SessionManager().sessionEpoch;
|
||||||
try {
|
try {
|
||||||
final tableData = await Localstore.instance
|
final entry = await CacheStore.instance.read(document);
|
||||||
.collection(collection)
|
var lastUpdate = entry?.lastUpdate ?? 0;
|
||||||
.doc(document)
|
T? cached;
|
||||||
.get();
|
if (entry != null) {
|
||||||
if (tableData != null) {
|
try {
|
||||||
final cached = onLocalData(tableData['json'] as String);
|
cached = fromCacheJson(await CacheStore.decode(entry.json));
|
||||||
|
} on Object {
|
||||||
|
// A payload from an older model version: treat it as a miss.
|
||||||
|
await CacheStore.instance.delete(document);
|
||||||
|
lastUpdate = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cached != null) {
|
||||||
onUpdate?.call(cached);
|
onUpdate?.call(cached);
|
||||||
onCacheData?.call(cached);
|
onCacheData?.call(cached);
|
||||||
}
|
}
|
||||||
|
await _load(document, epoch, lastUpdate: lastUpdate);
|
||||||
final lastUpdate = (tableData?['lastupdate'] as num?) ?? 0;
|
|
||||||
if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) <
|
|
||||||
lastUpdate) {
|
|
||||||
if (renew == null || !renew!) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final newValue = await onLoad();
|
|
||||||
onUpdate?.call(newValue);
|
|
||||||
onNetworkData?.call(newValue);
|
|
||||||
unawaited(
|
|
||||||
Localstore.instance.collection(collection).doc(document).set({
|
|
||||||
'json': jsonEncode(newValue),
|
|
||||||
'lastupdate': DateTime.now().millisecondsSinceEpoch,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} on Exception catch (e) {
|
|
||||||
onError(e);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!_ready.isCompleted) _ready.complete();
|
if (!_ready.isCompleted) _ready.complete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
T onLocalData(String json);
|
Future<void> _load(
|
||||||
|
String document,
|
||||||
|
int epoch, {
|
||||||
|
required int lastUpdate,
|
||||||
|
}) async {
|
||||||
|
if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) <
|
||||||
|
lastUpdate) {
|
||||||
|
if (renew == null || !renew!) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final newValue = await onLoad();
|
||||||
|
// The cache is shared, so a late response of a signed-out
|
||||||
|
// account would otherwise be cached for the next one.
|
||||||
|
if (!SessionManager().isCurrentSession(epoch)) {
|
||||||
|
onError(const StaleSessionException());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onUpdate?.call(newValue);
|
||||||
|
onNetworkData?.call(newValue);
|
||||||
|
unawaited(CacheStore.instance.write(document, jsonEncode(newValue)));
|
||||||
|
} on Exception catch (e) {
|
||||||
|
onError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
T fromCacheJson(Map<String, dynamic> json);
|
||||||
Future<T> onLoad();
|
Future<T> onLoad();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,8 +131,7 @@ class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
|
|||||||
Future<T> onLoad() => _loader();
|
Future<T> onLoad() => _loader();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
T onLocalData(String json) =>
|
T fromCacheJson(Map<String, dynamic> json) => _fromJson(json);
|
||||||
_fromJson(jsonDecode(json) as Map<String, dynamic>);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Captures the latest cache payload (cached or network) and rethrows the
|
/// Captures the latest cache payload (cached or network) and rethrows the
|
||||||
@@ -141,8 +154,10 @@ Future<T> resolveFromCache<T extends ApiResponse?>(
|
|||||||
onError?.call(e);
|
onError?.call(e);
|
||||||
});
|
});
|
||||||
await cache.ready;
|
await cache.ready;
|
||||||
if (latest != null) return latest as T;
|
|
||||||
final err = capturedError;
|
final err = capturedError;
|
||||||
|
// `latest` may still hold the cache hit read before the sign-out.
|
||||||
|
if (err is StaleSessionException) throw err;
|
||||||
|
if (latest != null) return latest as T;
|
||||||
if (err != null) throw err;
|
if (err != null) throw err;
|
||||||
throw ParseException(
|
throw ParseException(
|
||||||
technicalDetails: operationName != null
|
technicalDetails: operationName != null
|
||||||
|
|||||||
+34
-24
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||||
|
|
||||||
|
import 'api/marianumconnect/marianumconnect_api.dart';
|
||||||
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
||||||
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
|
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
|
||||||
import 'main.dart';
|
import 'main.dart';
|
||||||
@@ -45,18 +46,15 @@ class App extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AppState extends State<App> with WidgetsBindingObserver {
|
class _AppState extends State<App> with WidgetsBindingObserver {
|
||||||
late Timer _updateTimings;
|
|
||||||
StreamSubscription<RemoteMessage>? _onMessageSub;
|
StreamSubscription<RemoteMessage>? _onMessageSub;
|
||||||
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
|
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
|
||||||
StreamSubscription<String>? _fcmTokenRefreshSub;
|
StreamSubscription<String>? _fcmTokenRefreshSub;
|
||||||
int _knownTotalTabs = 1;
|
int _knownTotalTabs = 1;
|
||||||
int _lastTabIndex = 0;
|
int _lastTabIndex = 0;
|
||||||
bool _userOnLastTab = false;
|
bool _userOnLastTab = false;
|
||||||
DateTime? _lastTelemetryAt;
|
|
||||||
|
|
||||||
static const Duration _chatListActiveInterval = Duration(seconds: 15);
|
static const Duration _chatListActiveInterval = Duration(seconds: 15);
|
||||||
static const Duration _chatListIdleInterval = Duration(seconds: 60);
|
static const Duration _chatListIdleInterval = Duration(seconds: 60);
|
||||||
static const Duration _telemetryInterval = Duration(minutes: 15);
|
|
||||||
|
|
||||||
void _onTabControllerChanged() {
|
void _onTabControllerChanged() {
|
||||||
final newIndex = Main.bottomNavigator.index;
|
final newIndex = Main.bottomNavigator.index;
|
||||||
@@ -78,18 +76,15 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
bloc.setAutoRefreshInterval(
|
bloc.setAutoRefreshInterval(
|
||||||
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
|
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
|
||||||
);
|
);
|
||||||
if (talkIsActive && refresh) bloc.refresh();
|
// The poll keeps the list current; switching back to the tab shortly
|
||||||
|
// after a fetch doesn't need another one mid tab transition.
|
||||||
|
if (talkIsActive && refresh) {
|
||||||
|
bloc.refreshIfOlderThan(_chatListActiveInterval);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
|
|
||||||
// reliably while the app is suspended, so the window would still be open on
|
|
||||||
// the resume it is supposed to let through.
|
|
||||||
void _reportTelemetry() {
|
void _reportTelemetry() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final now = DateTime.now();
|
|
||||||
final last = _lastTelemetryAt;
|
|
||||||
if (last != null && now.difference(last) < _telemetryInterval) return;
|
|
||||||
_lastTelemetryAt = now;
|
|
||||||
TelemetryHeartbeat.report(
|
TelemetryHeartbeat.report(
|
||||||
notificationsEnabled: context
|
notificationsEnabled: context
|
||||||
.read<SettingsCubit>()
|
.read<SettingsCubit>()
|
||||||
@@ -103,6 +98,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
log('AppLifecycle: $state');
|
log('AppLifecycle: $state');
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
MarianumConnectApi.resetConnections();
|
||||||
_reportTelemetry();
|
_reportTelemetry();
|
||||||
Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () {
|
Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -113,6 +109,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
_syncChatListPolling(refresh: false);
|
_syncChatListPolling(refresh: false);
|
||||||
_handlePendingWidgetNavigation();
|
_handlePendingWidgetNavigation();
|
||||||
} else if (mounted) {
|
} else if (mounted) {
|
||||||
|
context.read<SettingsCubit>().flushSilentSave();
|
||||||
// Stop polling while backgrounded: a silent refresh failing in the
|
// Stop polling while backgrounded: a silent refresh failing in the
|
||||||
// background would otherwise leave an error that flashes on the next
|
// background would otherwise leave an error that flashes on the next
|
||||||
// resume before the foreground refetch replaces it.
|
// resume before the foreground refetch replaces it.
|
||||||
@@ -162,7 +159,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
void _handlePendingShare() {
|
void _handlePendingShare() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final share = ShareIntentListener.pending.value;
|
final share = ShareIntentListener.pending.value;
|
||||||
if (share == null) return;
|
if (share == null || ShareIntentListener.instance.isShown(share)) return;
|
||||||
// A second share would otherwise leave the previous share-flow page
|
// A second share would otherwise leave the previous share-flow page
|
||||||
// on top with stale (already-cleared) file paths.
|
// on top with stale (already-cleared) file paths.
|
||||||
// Sharing targets Talk chats and Files folders only.
|
// Sharing targets Talk chats and Files folders only.
|
||||||
@@ -181,6 +178,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
if (navigator.canPop()) {
|
if (navigator.canPop()) {
|
||||||
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
|
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
|
||||||
}
|
}
|
||||||
|
ShareIntentListener.instance.markShown(share);
|
||||||
AppRoutes.openShareTarget(context, share);
|
AppRoutes.openShareTarget(context, share);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,19 +196,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
context.read<BreakerBloc>().refresh();
|
context.read<BreakerBloc>().refresh();
|
||||||
context.read<ChatListBloc>().refresh();
|
context.read<ChatListBloc>().refresh();
|
||||||
// Initial publish in case hydrated storage already has data. No refresh
|
// Initial publish in case hydrated storage already has data. No refresh
|
||||||
// needed: PrimaryTimetableScope hands out a freshly loading bloc.
|
// needed: PrimaryTimetableScope hands out a freshly loading bloc. The
|
||||||
_publishWidget(context.read<TimetableBloc>());
|
// widget still shows its last snapshot, so this waits until the
|
||||||
|
// cold-start frames are done (a bloc emit in the meantime supersedes it).
|
||||||
|
Debouncer.debounce('widgetPublish', const Duration(seconds: 3), () {
|
||||||
|
if (mounted) _publishWidget(context.read<TimetableBloc>());
|
||||||
|
});
|
||||||
unawaited(_handlePendingWidgetNavigation());
|
unawaited(_handlePendingWidgetNavigation());
|
||||||
ShareIntentListener.instance.attach();
|
|
||||||
ShareIntentListener.pending.addListener(_handlePendingShare);
|
ShareIntentListener.pending.addListener(_handlePendingShare);
|
||||||
_handlePendingShare();
|
_handlePendingShare();
|
||||||
_syncChatListPolling();
|
_syncChatListPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
});
|
|
||||||
|
|
||||||
_reportTelemetry();
|
_reportTelemetry();
|
||||||
|
|
||||||
// A refreshed FCM token invalidates the existing push subscription — the
|
// A refreshed FCM token invalidates the existing push subscription — the
|
||||||
@@ -253,18 +250,20 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
NotificationController.onAppOpenedByNotification(message, context);
|
NotificationController.onAppOpenedByNotification(message, context);
|
||||||
});
|
});
|
||||||
|
|
||||||
DataCleaner.cleanOldCache();
|
// Housekeeping only; kept out of the cold-start window.
|
||||||
|
Future<void>.delayed(
|
||||||
|
const Duration(seconds: 20),
|
||||||
|
DataCleaner.cleanOldCache,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_updateTimings.cancel();
|
|
||||||
_onMessageSub?.cancel();
|
_onMessageSub?.cancel();
|
||||||
_onMessageOpenedAppSub?.cancel();
|
_onMessageOpenedAppSub?.cancel();
|
||||||
_fcmTokenRefreshSub?.cancel();
|
_fcmTokenRefreshSub?.cancel();
|
||||||
PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
|
PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
|
||||||
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
||||||
ShareIntentListener.instance.detach();
|
|
||||||
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -280,7 +279,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
// one widget reload for the burst is enough.
|
// one widget reload for the burst is enough.
|
||||||
listener: (_, _) => Debouncer.debounce(
|
listener: (_, _) => Debouncer.debounce(
|
||||||
'widgetPublish',
|
'widgetPublish',
|
||||||
const Duration(milliseconds: 500),
|
const Duration(seconds: 1),
|
||||||
() {
|
() {
|
||||||
if (mounted) _publishWidget(context.read<TimetableBloc>());
|
if (mounted) _publishWidget(context.read<TimetableBloc>());
|
||||||
},
|
},
|
||||||
@@ -290,7 +289,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
Widget _buildShell(
|
Widget _buildShell(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
) => BlocBuilder<SettingsCubit, model.Settings>(
|
) => BlocSelector<SettingsCubit, model.Settings, Object>(
|
||||||
|
// Only the module layout shapes the shell; copied values because the
|
||||||
|
// settings object is mutated in place.
|
||||||
|
selector: (settings) {
|
||||||
|
final m = settings.modulesSettings;
|
||||||
|
return (
|
||||||
|
m.moduleOrder.join(','),
|
||||||
|
m.hiddenModules.join(','),
|
||||||
|
m.autoFillBottomBar,
|
||||||
|
m.fixedBottomBarSlots,
|
||||||
|
);
|
||||||
|
},
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final bottomBarModules = AppModule.getBottomBarModules(context);
|
final bottomBarModules = AppModule.getBottomBarModules(context);
|
||||||
final totalTabs = bottomBarModules.length + 1;
|
final totalTabs = bottomBarModules.length + 1;
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ extension CalendarDayArithmetic on DateTime {
|
|||||||
);
|
);
|
||||||
|
|
||||||
DateTime subtractDays(int days) => addDays(-days);
|
DateTime subtractDays(int days) => addDays(-days);
|
||||||
|
|
||||||
|
DateTime get dateOnly => DateTime(year, month, day);
|
||||||
|
|
||||||
|
/// Monday 00:00 of this date's week.
|
||||||
|
DateTime get mondayOfWeek => subtractDays(weekday - 1).dateOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formatting helpers backed by Jiffy. Centralises the patterns that previously
|
/// Formatting helpers backed by Jiffy. Centralises the patterns that previously
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
extension TextExt on Text {
|
extension TextExt on Text {
|
||||||
Size get size {
|
/// Single-line width as rendered in [context] (honours the text scaler).
|
||||||
final textPainter = TextPainter(
|
/// Memoised: chat bubbles measure the same few names and times over and
|
||||||
|
/// over, and every measurement is a full paragraph layout.
|
||||||
|
double measuredWidth(BuildContext context) {
|
||||||
|
final scaler = MediaQuery.textScalerOf(context);
|
||||||
|
final key = (data, style, scaler);
|
||||||
|
final cached = _widthCache[key];
|
||||||
|
if (cached != null) return cached;
|
||||||
|
final painter = TextPainter(
|
||||||
text: TextSpan(text: data, style: style),
|
text: TextSpan(text: data, style: style),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
textDirection: TextDirection.ltr,
|
textDirection: TextDirection.ltr,
|
||||||
)..layout(minWidth: 0, maxWidth: double.infinity);
|
textScaler: scaler,
|
||||||
return textPainter.size;
|
)..layout();
|
||||||
|
final width = painter.width;
|
||||||
|
painter.dispose();
|
||||||
|
if (_widthCache.length >= 512) _widthCache.clear();
|
||||||
|
return _widthCache[key] = width;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Map<(String?, TextStyle?, TextScaler), double> _widthCache = {};
|
||||||
|
|
||||||
/// Returns the first non-empty (after trim) entry, or '' if none match.
|
/// Returns the first non-empty (after trim) entry, or '' if none match.
|
||||||
String firstNonEmpty(List<String?> values) {
|
String firstNonEmpty(List<String?> values) {
|
||||||
for (final v in values) {
|
for (final v in values) {
|
||||||
|
|||||||
+162
-117
@@ -7,6 +7,7 @@ import 'package:firebase_core/firebase_core.dart';
|
|||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart' show timeDilation;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
@@ -14,8 +15,8 @@ import 'package:jiffy/jiffy.dart';
|
|||||||
import 'package:loader_overlay/loader_overlay.dart';
|
import 'package:loader_overlay/loader_overlay.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
|
import 'access/user_role.dart';
|
||||||
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
|
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
|
||||||
import 'api/marianumconnect/auth/session_validator.dart';
|
import 'api/marianumconnect/auth/session_validator.dart';
|
||||||
import 'api/marianumconnect/marianumconnect_endpoint.dart';
|
import 'api/marianumconnect/marianumconnect_endpoint.dart';
|
||||||
@@ -26,6 +27,7 @@ import 'app.dart';
|
|||||||
import 'auth_link/guardian_link_listener.dart';
|
import 'auth_link/guardian_link_listener.dart';
|
||||||
import 'background/widget_background_task.dart';
|
import 'background/widget_background_task.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
|
import 'model/session_wipe.dart';
|
||||||
import 'notification/notification_service.dart';
|
import 'notification/notification_service.dart';
|
||||||
import 'push/notification_permission_prompt.dart';
|
import 'push/notification_permission_prompt.dart';
|
||||||
import 'push/push_message_handler.dart';
|
import 'push/push_message_handler.dart';
|
||||||
@@ -40,6 +42,7 @@ import 'share_intent/share_intent_listener.dart';
|
|||||||
import 'state/app/modules/account/account_scope.dart';
|
import 'state/app/modules/account/account_scope.dart';
|
||||||
import 'state/app/modules/account/bloc/account_bloc.dart';
|
import 'state/app/modules/account/bloc/account_bloc.dart';
|
||||||
import 'state/app/modules/account/bloc/account_state.dart';
|
import 'state/app/modules/account/bloc/account_state.dart';
|
||||||
|
import 'state/app/modules/app_modules.dart';
|
||||||
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||||
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
|
||||||
@@ -57,6 +60,7 @@ import 'view/login/login.dart';
|
|||||||
import 'view/login/post_login_splash.dart';
|
import 'view/login/post_login_splash.dart';
|
||||||
import 'widget/avatar_disk_cache.dart';
|
import 'widget/avatar_disk_cache.dart';
|
||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
|
import 'widget/debug/debug_stats_overlay.dart';
|
||||||
import 'widget/downloads/download_tray.dart';
|
import 'widget/downloads/download_tray.dart';
|
||||||
import 'widget/emergency/emergency_notice_gate.dart';
|
import 'widget/emergency/emergency_notice_gate.dart';
|
||||||
import 'widget/info_dialog.dart';
|
import 'widget/info_dialog.dart';
|
||||||
@@ -120,6 +124,7 @@ void _installErrorHandlers() {
|
|||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
log('MarianumMobile started');
|
log('MarianumMobile started');
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
SessionManager().markUiEngine();
|
||||||
// Before any initialisation so startup failures reach the backend too.
|
// Before any initialisation so startup failures reach the backend too.
|
||||||
_installErrorHandlers();
|
_installErrorHandlers();
|
||||||
|
|
||||||
@@ -179,6 +184,12 @@ Future<void> main() async {
|
|||||||
);
|
);
|
||||||
log('app initialisation done!');
|
log('app initialisation done!');
|
||||||
|
|
||||||
|
// Independent of the notification setup below, so it runs alongside it.
|
||||||
|
final widgetSyncInit = _startupStep(
|
||||||
|
'widget sync',
|
||||||
|
WidgetSync.ensureInitialized,
|
||||||
|
);
|
||||||
|
|
||||||
// Local notifications: init the plugin (with tap/action callbacks) and the
|
// Local notifications: init the plugin (with tap/action callbacks) and the
|
||||||
// Android channels, then register the FCM background isolate handler that
|
// Android channels, then register the FCM background isolate handler that
|
||||||
// decrypts and renders Nextcloud pushes while the app is not in foreground.
|
// decrypts and renders Nextcloud pushes while the app is not in foreground.
|
||||||
@@ -198,9 +209,9 @@ Future<void> main() async {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wire up the home-screen widget bridge before runApp so any widget render
|
// The home-screen widget bridge must be ready before runApp so any widget
|
||||||
// triggered during startup hits initialised native storage.
|
// render triggered during startup hits initialised native storage.
|
||||||
await _startupStep('widget sync', WidgetSync.ensureInitialized);
|
await widgetSyncInit;
|
||||||
unawaited(
|
unawaited(
|
||||||
WidgetBackgroundTask.initialize().onError(
|
WidgetBackgroundTask.initialize().onError(
|
||||||
(e, _) => log('Workmanager init failed: $e'),
|
(e, _) => log('Workmanager init failed: $e'),
|
||||||
@@ -297,13 +308,19 @@ class Main extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MainState extends State<Main> {
|
class _MainState extends State<Main> {
|
||||||
|
final List<NavigatorObserver> _navigatorObservers = [
|
||||||
|
AppRoutes.chatRouteObserver,
|
||||||
|
DownloadRouteObserver(),
|
||||||
|
];
|
||||||
bool _showPostLoginSplash = false;
|
bool _showPostLoginSplash = false;
|
||||||
bool _appMounted = true;
|
bool _appMounted = true;
|
||||||
|
late AccountStatus _lastStatus;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
Jiffy.setLocale('de');
|
Jiffy.setLocale('de');
|
||||||
|
_lastStatus = context.read<AccountBloc>().state.status;
|
||||||
|
|
||||||
SessionManager().unauthorizedSignal.addListener(_onUnauthorized);
|
SessionManager().unauthorizedSignal.addListener(_onUnauthorized);
|
||||||
GuardianLinkListener.pending.addListener(_onGuardianLink);
|
GuardianLinkListener.pending.addListener(_onGuardianLink);
|
||||||
@@ -332,8 +349,10 @@ class _MainState extends State<Main> {
|
|||||||
final capabilitiesCubit = scopeContext.read<CapabilitiesCubit>();
|
final capabilitiesCubit = scopeContext.read<CapabilitiesCubit>();
|
||||||
unawaited(
|
unawaited(
|
||||||
capabilitiesCubit.load().then((_) {
|
capabilitiesCubit.load().then((_) {
|
||||||
if (!mounted) return;
|
// Closed: the account was switched away from while loading.
|
||||||
|
if (!mounted || capabilitiesCubit.isClosed) return;
|
||||||
_syncPush(settingsCubit, capabilitiesCubit);
|
_syncPush(settingsCubit, capabilitiesCubit);
|
||||||
|
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
|
||||||
_promptGuardianNotifications();
|
_promptGuardianNotifications();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -393,6 +412,19 @@ class _MainState extends State<Main> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// userType is only known once capabilities are loaded.
|
||||||
|
void _applyRoleDefaults(
|
||||||
|
SettingsCubit settings,
|
||||||
|
CapabilitiesCubit capabilities,
|
||||||
|
) {
|
||||||
|
if (AppModule.applyTeacherDefaults(
|
||||||
|
settings.val().modulesSettings,
|
||||||
|
isTeacher: capabilities.state.role == UserRole.teacher,
|
||||||
|
)) {
|
||||||
|
settings.val(write: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
SessionManager().unauthorizedSignal.removeListener(_onUnauthorized);
|
SessionManager().unauthorizedSignal.removeListener(_onUnauthorized);
|
||||||
@@ -444,55 +476,72 @@ class _MainState extends State<Main> {
|
|||||||
if (mounted) setState(() => _appMounted = true);
|
if (mounted) setState(() => _appMounted = true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (accountState.status == AccountStatus.addingAccount) {
|
final wasLoggedIn = _lastStatus == AccountStatus.loggedIn;
|
||||||
// Routes pushed on the root navigator (Settings) would otherwise keep
|
_lastStatus = accountState.status;
|
||||||
// covering the login screen that replaces the home route.
|
// A cold start that merely resolves as signed out has nothing to wipe,
|
||||||
AppRoutes.rootNavigatorKey.currentState?.popUntil(
|
// and a share waiting for the login must survive it.
|
||||||
(route) => route.isFirst,
|
if (accountState.status != AccountStatus.loggedOut || !wasLoggedIn) return;
|
||||||
);
|
|
||||||
}
|
|
||||||
if (accountState.status != AccountStatus.loggedOut) return;
|
|
||||||
// A pending share would otherwise survive logout and be re-applied after
|
|
||||||
// re-login with file paths the OS may already have evicted from the cache.
|
|
||||||
ShareIntentListener.instance.clear();
|
|
||||||
// Deferred until the account-scoped tree is torn down.
|
// Deferred until the account-scoped tree is torn down.
|
||||||
WidgetsBinding.instance.addPostFrameCallback(
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
(_) => unawaited(_wipeDeviceState()),
|
(_) => unawaited(SessionWipe.deviceLeft()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _scopeAccountId;
|
/// The account-scoped tree only lives while its account is the one in use:
|
||||||
|
/// adding another account parks it, so its blocs go as well.
|
||||||
|
static Object _scopeOf(AccountState state) => (
|
||||||
|
state.accountId,
|
||||||
|
state.status == AccountStatus.addingAccount,
|
||||||
|
);
|
||||||
|
|
||||||
|
Object? _scope;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Directionality(
|
Widget build(BuildContext context) => Directionality(
|
||||||
textDirection: TextDirection.ltr,
|
textDirection: TextDirection.ltr,
|
||||||
child: BlocBuilder<SettingsCubit, Settings>(
|
// Selects only what the root uses: Settings is mutated in place and
|
||||||
builder: (context, settings) {
|
// re-emitted as a fresh instance on every write, so a plain BlocBuilder
|
||||||
|
// would rebuild MaterialApp (and every route) for unrelated settings.
|
||||||
|
child: BlocSelector<SettingsCubit, Settings, _RootSettings>(
|
||||||
|
selector: (settings) => (
|
||||||
|
appTheme: settings.appTheme,
|
||||||
|
notificationsEnabled: settings.notificationSettings.enabled,
|
||||||
|
showPerformanceOverlay:
|
||||||
|
settings.devToolsSettings.showPerformanceOverlay,
|
||||||
|
showSemanticsDebugger: settings.devToolsSettings.showSemanticsDebugger,
|
||||||
|
showFrameStats: settings.devToolsSettings.showFrameStats,
|
||||||
|
showMemoryStats: settings.devToolsSettings.showMemoryStats,
|
||||||
|
slowAnimations: settings.devToolsSettings.slowAnimations,
|
||||||
|
textScaleOverride: settings.devToolsSettings.textScaleOverride,
|
||||||
|
),
|
||||||
|
builder: (context, root) {
|
||||||
// Mirror the notification toggle into group-scoped storage so the FCM
|
// Mirror the notification toggle into group-scoped storage so the FCM
|
||||||
// background isolate and the iOS NSE can suppress rendering when off.
|
// background isolate and the iOS NSE can suppress rendering when off.
|
||||||
unawaited(
|
unawaited(
|
||||||
const PushRegistrationStore().setNotificationsEnabled(
|
const PushRegistrationStore().setNotificationsEnabled(
|
||||||
settings.notificationSettings.enabled,
|
root.notificationsEnabled,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
timeDilation = root.slowAnimations ? 5.0 : 1.0;
|
||||||
return BlocConsumer<AccountBloc, AccountState>(
|
return BlocConsumer<AccountBloc, AccountState>(
|
||||||
listenWhen: (previous, current) =>
|
listenWhen: (previous, current) =>
|
||||||
previous.status != current.status ||
|
previous.status != current.status ||
|
||||||
previous.accountId != current.accountId,
|
previous.accountId != current.accountId,
|
||||||
listener: _onAccountChanged,
|
listener: _onAccountChanged,
|
||||||
buildWhen: (previous, current) =>
|
buildWhen: (previous, current) =>
|
||||||
previous.accountId != current.accountId,
|
_scopeOf(previous) != _scopeOf(current),
|
||||||
builder: (context, account) {
|
builder: (context, account) {
|
||||||
if (account.accountId != _scopeAccountId) {
|
final scope = _scopeOf(account);
|
||||||
_scopeAccountId = account.accountId;
|
if (scope != _scope) {
|
||||||
|
_scope = scope;
|
||||||
// The old navigator (and every route on it) goes with the old
|
// The old navigator (and every route on it) goes with the old
|
||||||
// account; a shared GlobalKey would carry it over instead.
|
// account; a shared GlobalKey would carry it over instead.
|
||||||
AppRoutes.rootNavigatorKey = GlobalKey<NavigatorState>();
|
AppRoutes.rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
}
|
}
|
||||||
return AccountScope(
|
return AccountScope(
|
||||||
key: ValueKey(account.accountId),
|
key: ValueKey(scope),
|
||||||
onActive: _onSessionActive,
|
onActive: _onSessionActive,
|
||||||
child: _buildApp(settings),
|
child: _buildApp(root),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -500,101 +549,97 @@ class _MainState extends State<Main> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildApp(Settings settings) {
|
Widget _buildApp(_RootSettings root) => MaterialApp(
|
||||||
final devToolsSettings = settings.devToolsSettings;
|
showPerformanceOverlay: root.showPerformanceOverlay,
|
||||||
return MaterialApp(
|
showSemanticsDebugger: root.showSemanticsDebugger,
|
||||||
showPerformanceOverlay: devToolsSettings.showPerformanceOverlay,
|
debugShowCheckedModeBanner: false,
|
||||||
checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers,
|
navigatorKey: AppRoutes.rootNavigatorKey,
|
||||||
checkerboardRasterCacheImages:
|
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
|
||||||
devToolsSettings.checkerboardRasterCacheImages,
|
// DownloadRouteObserver tracks full-page navigations so the downloads
|
||||||
debugShowCheckedModeBanner: false,
|
// chip only surfaces once the user leaves the screen they started on.
|
||||||
navigatorKey: AppRoutes.rootNavigatorKey,
|
navigatorObservers: _navigatorObservers,
|
||||||
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
|
localizationsDelegates: const [
|
||||||
// DownloadRouteObserver tracks full-page navigations so the downloads
|
...GlobalMaterialLocalizations.delegates,
|
||||||
// chip only surfaces once the user leaves the screen they started on.
|
GlobalWidgetsLocalizations.delegate,
|
||||||
navigatorObservers: [
|
],
|
||||||
AppRoutes.chatRouteObserver,
|
supportedLocales: const [Locale('de'), Locale('en')],
|
||||||
DownloadRouteObserver(),
|
locale: const Locale('de'),
|
||||||
],
|
title: 'Marianum Fulda',
|
||||||
localizationsDelegates: const [
|
themeMode: root.appTheme,
|
||||||
...GlobalMaterialLocalizations.delegates,
|
theme: LightAppTheme.theme,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
darkTheme: DarkAppTheme.theme,
|
||||||
],
|
// Brand-colored backdrop behind every route. During the logout
|
||||||
supportedLocales: const [Locale('de'), Locale('en')],
|
// home-swap and route pop animations the framework can briefly
|
||||||
locale: const Locale('de'),
|
// expose the layer below the topmost Scaffold; without this
|
||||||
title: 'Marianum Fulda',
|
// the dark Material default shows through and the user sees a
|
||||||
themeMode: settings.appTheme,
|
// black flash.
|
||||||
theme: LightAppTheme.theme,
|
builder: (context, child) {
|
||||||
darkTheme: DarkAppTheme.theme,
|
Widget app = ColoredBox(
|
||||||
// Brand-colored backdrop behind every route. During the logout
|
|
||||||
// home-swap and route pop animations the framework can briefly
|
|
||||||
// expose the layer below the topmost Scaffold; without this
|
|
||||||
// the dark Material default shows through and the user sees a
|
|
||||||
// black flash.
|
|
||||||
builder: (context, child) => ColoredBox(
|
|
||||||
color: LightAppTheme.marianumRed,
|
color: LightAppTheme.marianumRed,
|
||||||
// Downloads tray mounted ABOVE the navigator so its chip floats over
|
// Downloads tray mounted ABOVE the navigator so its chip floats
|
||||||
// every route (folder views, chat, viewer are full-page pushes that
|
// over every route (folder views, chat, viewer are full-page
|
||||||
// would otherwise cover it).
|
// pushes that would otherwise cover it).
|
||||||
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
|
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
|
||||||
),
|
);
|
||||||
home: EmergencyNoticeGate(
|
final scale = root.textScaleOverride;
|
||||||
child: LoaderOverlay(
|
if (scale > 0) {
|
||||||
child: Breaker(
|
app = MediaQuery.withClampedTextScaling(
|
||||||
breaker: BreakerArea.global,
|
minScaleFactor: scale,
|
||||||
child: BlocBuilder<AccountBloc, AccountState>(
|
maxScaleFactor: scale,
|
||||||
buildWhen: (previous, current) =>
|
child: app,
|
||||||
previous.status != current.status,
|
);
|
||||||
builder: (context, accountState) {
|
}
|
||||||
switch (accountState.status) {
|
return DebugStatsOverlay(
|
||||||
case AccountStatus.loggedIn:
|
showFrameStats: root.showFrameStats,
|
||||||
return Stack(
|
showMemoryStats: root.showMemoryStats,
|
||||||
fit: StackFit.expand,
|
child: app,
|
||||||
children: [
|
);
|
||||||
if (_appMounted) const App(key: ValueKey('app-shell')),
|
},
|
||||||
if (_showPostLoginSplash)
|
home: EmergencyNoticeGate(
|
||||||
PostLoginSplash(
|
child: LoaderOverlay(
|
||||||
key: const ValueKey('post-login-splash'),
|
child: Breaker(
|
||||||
onComplete: () {
|
breaker: BreakerArea.global,
|
||||||
setState(() => _showPostLoginSplash = false);
|
child: BlocBuilder<AccountBloc, AccountState>(
|
||||||
_promptGuardianNotifications();
|
buildWhen: (previous, current) => previous.status != current.status,
|
||||||
},
|
builder: (context, accountState) {
|
||||||
),
|
switch (accountState.status) {
|
||||||
],
|
case AccountStatus.loggedIn:
|
||||||
);
|
return Stack(
|
||||||
case AccountStatus.loggedOut:
|
fit: StackFit.expand,
|
||||||
return const Login();
|
children: [
|
||||||
case AccountStatus.addingAccount:
|
if (_appMounted) const App(key: ValueKey('app-shell')),
|
||||||
return const Login(addingAccount: true);
|
if (_showPostLoginSplash)
|
||||||
case AccountStatus.undefined:
|
PostLoginSplash(
|
||||||
return const AccountLoadingScreen();
|
key: const ValueKey('post-login-splash'),
|
||||||
}
|
onComplete: () {
|
||||||
},
|
setState(() => _showPostLoginSplash = false);
|
||||||
),
|
_promptGuardianNotifications();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
case AccountStatus.loggedOut:
|
||||||
|
return const Login();
|
||||||
|
case AccountStatus.addingAccount:
|
||||||
|
return const Login(addingAccount: true);
|
||||||
|
case AccountStatus.undefined:
|
||||||
|
return const AccountLoadingScreen();
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wipes what outlives accounts once the last one signed out. Account data
|
typedef _RootSettings = ({
|
||||||
/// itself is removed by [SessionLifecycle.signOut].
|
ThemeMode appTheme,
|
||||||
Future<void> _wipeDeviceState() async {
|
bool notificationsEnabled,
|
||||||
try {
|
bool showPerformanceOverlay,
|
||||||
final prefs = await SharedPreferences.getInstance();
|
bool showSemanticsDebugger,
|
||||||
await prefs.clear();
|
bool showFrameStats,
|
||||||
// The chat background image lives outside HydratedStorage, so clear it too
|
bool showMemoryStats,
|
||||||
// (best-effort) to avoid orphaning the previous user's wallpaper.
|
bool slowAnimations,
|
||||||
final backgroundImage = File(AppPaths.chatBackgroundImage);
|
double textScaleOverride,
|
||||||
if (backgroundImage.existsSync()) backgroundImage.deleteSync();
|
});
|
||||||
// Stop the periodic widget refresh job so the background isolate doesn't
|
|
||||||
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
|
|
||||||
// on the next successful login.
|
|
||||||
await WidgetBackgroundTask.cancelAll();
|
|
||||||
await WidgetSync.clear();
|
|
||||||
await WidgetSync.triggerUpdate();
|
|
||||||
} catch (e, s) {
|
|
||||||
log('Device state wipe failed: $e', stackTrace: s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,8 @@
|
|||||||
import 'package:localstore/localstore.dart';
|
import '../api/cache_store.dart';
|
||||||
|
|
||||||
import '../api/request_cache.dart';
|
|
||||||
|
|
||||||
class DataCleaner {
|
class DataCleaner {
|
||||||
static Future<void> cleanOldCache() async {
|
static Future<void> cleanOldCache() async {
|
||||||
final cacheData = await Localstore.instance
|
await CacheStore.deleteLegacyLocalstoreCache();
|
||||||
.collection(RequestCache.collection)
|
await CacheStore.instance.deleteOlderThan(const Duration(days: 200));
|
||||||
.get();
|
|
||||||
cacheData?.forEach((key, value) async {
|
|
||||||
final lastUpdate = DateTime.fromMillisecondsSinceEpoch(
|
|
||||||
((value['lastupdate'] as num?) ?? 0).toInt(),
|
|
||||||
);
|
|
||||||
if (DateTime.now()
|
|
||||||
.subtract(const Duration(days: 200))
|
|
||||||
.isAfter(lastUpdate)) {
|
|
||||||
await Localstore.instance
|
|
||||||
.collection(RequestCache.collection)
|
|
||||||
.doc(key.split('/').last)
|
|
||||||
.delete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../api/marianumcloud/talk/share_files_to_chat.dart';
|
||||||
|
import '../background/widget_background_task.dart';
|
||||||
|
import '../notification/notification_service.dart';
|
||||||
|
import '../push/chat_thread_store.dart';
|
||||||
|
import '../push/nid_store.dart';
|
||||||
|
import '../push/push_keypair.dart';
|
||||||
|
import '../push/push_tap_router.dart';
|
||||||
|
import '../routing/app_routes.dart';
|
||||||
|
import '../share_intent/share_intent_listener.dart';
|
||||||
|
import '../utils/app_paths.dart';
|
||||||
|
import '../utils/downloads/download_manager.dart';
|
||||||
|
import '../utils/file_clipboard.dart';
|
||||||
|
import '../widget_data/widget_sync.dart';
|
||||||
|
|
||||||
|
/// Removes what an account leaves on the device outside its own storage (see
|
||||||
|
/// `AccountStorage` for that). Every step is isolated: one failing step used
|
||||||
|
/// to skip all later ones and leave the previous account's data behind.
|
||||||
|
abstract final class SessionWipe {
|
||||||
|
static Future<void>? _running;
|
||||||
|
|
||||||
|
/// Completes once a running wipe finished. A login awaits it, otherwise the
|
||||||
|
/// wipe could clear what the next account just stored (widget job, caches).
|
||||||
|
static Future<void> get done => _running ?? Future.value();
|
||||||
|
|
||||||
|
/// State that must be gone before the next frame (a login screen or another
|
||||||
|
/// account can be reached right away): pending navigation and in-memory
|
||||||
|
/// singletons.
|
||||||
|
static void clearImmediate() {
|
||||||
|
_step('share intents', ShareIntentListener.instance.clearAll);
|
||||||
|
_step('share folder cache', resetTalkShareFolderCache);
|
||||||
|
_step('pending navigation', () {
|
||||||
|
AppRoutes.pendingChatToken.value = null;
|
||||||
|
PushTapRouter.pendingTarget.value = null;
|
||||||
|
});
|
||||||
|
_step('file clipboard', FileClipboard.instance.clear);
|
||||||
|
_step('downloads', DownloadManager.instance.clearAll);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs whenever the active account stops being active (sign-out or
|
||||||
|
/// switch). The tray and its bookkeeping belong to that account — a tap or
|
||||||
|
/// inline reply there would otherwise act as the next one. A new keypair
|
||||||
|
/// and FCM token make pushes still addressed to the previous registration
|
||||||
|
/// undecryptable and undeliverable (an offline sign-out or switch could not
|
||||||
|
/// unregister it).
|
||||||
|
static Future<void> accountLeft() async {
|
||||||
|
await _stepAsync('download files', () async {
|
||||||
|
final dir = Directory('${(await getTemporaryDirectory()).path}/downloads');
|
||||||
|
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||||
|
});
|
||||||
|
await _stepAsync(
|
||||||
|
'notification tray',
|
||||||
|
NotificationService().flutterLocalNotificationsPlugin.cancelAll,
|
||||||
|
);
|
||||||
|
await _stepAsync('app badge', () => FlutterAppBadge.count(0));
|
||||||
|
await _stepAsync('push nid store', NidStore().clear);
|
||||||
|
await _stepAsync('push thread store', ChatThreadStore().clearAll);
|
||||||
|
await _stepAsync('push keypair', const PushKeypair().clear);
|
||||||
|
// Bounded: the next account waits for this, also without a network.
|
||||||
|
await _stepAsync(
|
||||||
|
'fcm token',
|
||||||
|
() => FirebaseMessaging.instance.deleteToken().timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wipes what outlives accounts, once the last one signed out.
|
||||||
|
static Future<void> deviceLeft() {
|
||||||
|
final wipe = _deviceLeft();
|
||||||
|
_running = wipe;
|
||||||
|
return wipe.whenComplete(() {
|
||||||
|
if (identical(_running, wipe)) _running = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _deviceLeft() async {
|
||||||
|
// SettingsCubit is deliberately left alone: its selector wraps
|
||||||
|
// MaterialApp, and emitting a fresh state here tore down the freshly
|
||||||
|
// mounted Login tree (blank screen until the next interaction).
|
||||||
|
await _stepAsync('shared preferences', () async {
|
||||||
|
await (await SharedPreferences.getInstance()).clear();
|
||||||
|
});
|
||||||
|
await _stepAsync('chat background', () async {
|
||||||
|
final image = File(AppPaths.chatBackgroundImage);
|
||||||
|
if (image.existsSync()) await image.delete();
|
||||||
|
});
|
||||||
|
// Stop the periodic widget refresh job so the background isolate doesn't
|
||||||
|
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
|
||||||
|
// on the next successful login.
|
||||||
|
await _stepAsync('widget task', WidgetBackgroundTask.cancelAll);
|
||||||
|
await _stepAsync('widget data', WidgetSync.clear);
|
||||||
|
await _stepAsync('widget update', WidgetSync.triggerUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _step(String name, void Function() step) {
|
||||||
|
try {
|
||||||
|
step();
|
||||||
|
} catch (e, s) {
|
||||||
|
log('Session wipe step "$name" failed: $e', stackTrace: s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _stepAsync(
|
||||||
|
String name,
|
||||||
|
Future<void> Function() step,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await step();
|
||||||
|
} catch (e, s) {
|
||||||
|
log('Session wipe step "$name" failed: $e', stackTrace: s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import '../push/push_message_handler.dart';
|
import '../push/push_message_handler.dart';
|
||||||
import '../push/push_target.dart';
|
import '../push/push_target.dart';
|
||||||
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
|
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
|
import '../utils/debouncer.dart';
|
||||||
import '../widget/debug/debug_tile.dart';
|
import '../widget/debug/debug_tile.dart';
|
||||||
import '../widget/debug/json_viewer.dart';
|
import '../widget/debug/json_viewer.dart';
|
||||||
import '../widget/info_dialog.dart';
|
import '../widget/info_dialog.dart';
|
||||||
@@ -35,7 +37,14 @@ class NotificationController {
|
|||||||
);
|
);
|
||||||
await NotificationTasks.refreshBadge();
|
await NotificationTasks.refreshBadge();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
NotificationTasks.updateProviders(context);
|
// A busy group chat delivers pushes in bursts; one silent refresh after
|
||||||
|
// the burst replaces a full room-list fetch per message.
|
||||||
|
final chatList = context.read<ChatListBloc>();
|
||||||
|
Debouncer.debounce(
|
||||||
|
'pushChatListRefresh',
|
||||||
|
const Duration(seconds: 1),
|
||||||
|
() => chatList.refresh(silent: true),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> onAppOpenedByNotification(
|
static Future<void> onAppOpenedByNotification(
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ class NotificationService {
|
|||||||
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
|
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
|
||||||
// so both platforms expose the same quick actions. The actual delivery of
|
// so both platforms expose the same quick actions. The actual delivery of
|
||||||
// these while the app is terminated is handled by the (Phase 3) NSE.
|
// these while the app is terminated is handled by the (Phase 3) NSE.
|
||||||
|
// The plugin defaults to prompting here, which would block startup on the
|
||||||
|
// OS dialog; the prompt belongs to maybePromptTalkNotifications instead.
|
||||||
final iosSettings = DarwinInitializationSettings(
|
final iosSettings = DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: false,
|
||||||
|
requestSoundPermission: false,
|
||||||
|
requestBadgePermission: false,
|
||||||
notificationCategories: [
|
notificationCategories: [
|
||||||
DarwinNotificationCategory(
|
DarwinNotificationCategory(
|
||||||
PushRenderer.iosTalkCategory,
|
PushRenderer.iosTalkCategory,
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ class PushActions {
|
|||||||
// auth header, the Talk POST never happens and the RemoteInput spinner
|
// auth header, the Talk POST never happens and the RemoteInput spinner
|
||||||
// runs forever.
|
// runs forever.
|
||||||
DartPluginRegistrant.ensureInitialized();
|
DartPluginRegistrant.ensureInitialized();
|
||||||
|
// The action engine lives as long as the process: without a reload a
|
||||||
|
// reply would be sent with the account that was signed in when the
|
||||||
|
// engine first started.
|
||||||
|
await SessionManager().reloadFromStorage();
|
||||||
|
|
||||||
_plog(
|
_plog(
|
||||||
'action=${response.actionId} payload=${response.payload} '
|
'action=${response.actionId} payload=${response.payload} '
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:crypton/crypton.dart';
|
import 'package:crypton/crypton.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:pointycastle/export.dart' as pc;
|
import 'package:pointycastle/export.dart' as pc;
|
||||||
|
|
||||||
import 'push_subject.dart';
|
import 'push_subject.dart';
|
||||||
@@ -25,6 +25,14 @@ class PushDecryptor {
|
|||||||
|
|
||||||
const PushDecryptor({required this.devicePrivateKey, this.serverPublicKey});
|
const PushDecryptor({required this.devicePrivateKey, this.serverPublicKey});
|
||||||
|
|
||||||
|
/// [verify] + [decrypt] on a background isolate: pure-Dart RSA-2048 (with
|
||||||
|
/// the OAEP → PKCS#1 fallback) takes tens of ms up to >100 ms on older
|
||||||
|
/// phones, which would otherwise land on the UI thread for every push.
|
||||||
|
Future<({bool verified, PushSubject? subject})> verifyAndDecryptInBackground(
|
||||||
|
String subjectBase64,
|
||||||
|
String signatureBase64,
|
||||||
|
) => compute(_verifyAndDecrypt, (this, subjectBase64, signatureBase64));
|
||||||
|
|
||||||
/// Returns true when [signatureBase64] is a valid server signature over the
|
/// Returns true when [signatureBase64] is a valid server signature over the
|
||||||
/// encrypted subject. Returns true when no server key is configured (the
|
/// encrypted subject. Returns true when no server key is configured (the
|
||||||
/// proxy already verified the signature before forwarding).
|
/// proxy already verified the signature before forwarding).
|
||||||
@@ -75,3 +83,13 @@ class PushDecryptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
({bool verified, PushSubject? subject}) _verifyAndDecrypt(
|
||||||
|
(PushDecryptor, String, String) args,
|
||||||
|
) {
|
||||||
|
final (decryptor, subject, signature) = args;
|
||||||
|
if (!decryptor.verify(subject, signature)) {
|
||||||
|
return (verified: false, subject: null);
|
||||||
|
}
|
||||||
|
return (verified: true, subject: decryptor.decrypt(subject));
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:firebase_messaging/firebase_messaging.dart';
|
|||||||
|
|
||||||
import '../background/widget_background_task.dart';
|
import '../background/widget_background_task.dart';
|
||||||
import '../notification/notification_service.dart';
|
import '../notification/notification_service.dart';
|
||||||
|
import '../session/session_manager.dart';
|
||||||
import 'chat_thread_store.dart';
|
import 'chat_thread_store.dart';
|
||||||
import 'nid_store.dart';
|
import 'nid_store.dart';
|
||||||
import 'push_decryptor.dart';
|
import 'push_decryptor.dart';
|
||||||
@@ -54,6 +55,8 @@ PushKind classifyPush(Map<String, dynamic> data) {
|
|||||||
/// "must be annotated"), so a plain function is the reliable form.
|
/// "must be annotated"), so a plain function is the reliable form.
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
|
Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
|
||||||
|
// This engine outlives sign-outs and logins in the app.
|
||||||
|
await SessionManager().reloadFromStorage();
|
||||||
await NotificationService().initializeNotifications();
|
await NotificationService().initializeNotifications();
|
||||||
await PushRenderer.ensureChannels();
|
await PushRenderer.ensureChannels();
|
||||||
await PushMessageHandler().handle(message);
|
await PushMessageHandler().handle(message);
|
||||||
@@ -175,11 +178,15 @@ class PushMessageHandler {
|
|||||||
devicePrivateKey: privateKey,
|
devicePrivateKey: privateKey,
|
||||||
serverPublicKey: serverPublicKey,
|
serverPublicKey: serverPublicKey,
|
||||||
);
|
);
|
||||||
if (!decryptor.verify(subjectBase64, signatureBase64)) {
|
final result = await decryptor.verifyAndDecryptInBackground(
|
||||||
|
subjectBase64,
|
||||||
|
signatureBase64,
|
||||||
|
);
|
||||||
|
if (!result.verified) {
|
||||||
log('Push: signature verification failed');
|
log('Push: signature verification failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final subject = decryptor.decrypt(subjectBase64);
|
final subject = result.subject;
|
||||||
if (subject == null) {
|
if (subject == null) {
|
||||||
log('Push: could not decrypt subject');
|
log('Push: could not decrypt subject');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ class PushRegistration {
|
|||||||
Future<bool> register() async {
|
Future<bool> register() async {
|
||||||
if (DemoMode.active) return false;
|
if (DemoMode.active) return false;
|
||||||
if (_nextcloudOrNull == null) return _direct.register();
|
if (_nextcloudOrNull == null) return _direct.register();
|
||||||
|
final epoch = SessionManager().sessionEpoch;
|
||||||
final String? fcmToken;
|
final String? fcmToken;
|
||||||
try {
|
try {
|
||||||
fcmToken = await FirebaseMessaging.instance.getToken();
|
fcmToken = await FirebaseMessaging.instance.getToken();
|
||||||
@@ -167,6 +168,7 @@ class PushRegistration {
|
|||||||
fcmToken: fcmToken,
|
fcmToken: fcmToken,
|
||||||
pems: pems,
|
pems: pems,
|
||||||
appVersion: appVersion,
|
appVersion: appVersion,
|
||||||
|
epoch: epoch,
|
||||||
);
|
);
|
||||||
allOk = allOk && ok;
|
allOk = allOk && ok;
|
||||||
}
|
}
|
||||||
@@ -178,6 +180,7 @@ class PushRegistration {
|
|||||||
required String fcmToken,
|
required String fcmToken,
|
||||||
required PushKeypairPems pems,
|
required PushKeypairPems pems,
|
||||||
required String? appVersion,
|
required String? appVersion,
|
||||||
|
required int epoch,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final proxyServer = currentProxyServer;
|
final proxyServer = currentProxyServer;
|
||||||
@@ -193,6 +196,11 @@ class PushRegistration {
|
|||||||
userAgent: isTalk ? _talkUserAgent : null,
|
userAgent: isTalk ? _talkUserAgent : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Signed out while registering: persisting or announcing this
|
||||||
|
// registration would keep delivering the previous account's pushes,
|
||||||
|
// and logoutCleanup already ran so nothing would unregister it.
|
||||||
|
if (!SessionManager().isCurrentSession(epoch)) return false;
|
||||||
|
|
||||||
await _store.save(
|
await _store.save(
|
||||||
type: type,
|
type: type,
|
||||||
deviceIdentifier: registration.deviceIdentifier,
|
deviceIdentifier: registration.deviceIdentifier,
|
||||||
@@ -355,9 +363,8 @@ class PushRegistration {
|
|||||||
status != AuthorizationStatus.deniedPermanently;
|
status != AuthorizationStatus.deniedPermanently;
|
||||||
|
|
||||||
/// Requests the OS notification permission (covers iOS + Android 13) and
|
/// Requests the OS notification permission (covers iOS + Android 13) and
|
||||||
/// returns whether registration should proceed. Errors from the plugin are
|
/// returns whether it is usable (not explicitly denied). Errors from the
|
||||||
/// treated as usable — better a possibly-idle registration than silently
|
/// plugin are treated as usable so a transient failure never nags the user.
|
||||||
/// losing push over a transient failure.
|
|
||||||
static Future<bool> requestOsPermission() async {
|
static Future<bool> requestOsPermission() async {
|
||||||
try {
|
try {
|
||||||
final settings = await FirebaseMessaging.instance.requestPermission();
|
final settings = await FirebaseMessaging.instance.requestPermission();
|
||||||
@@ -368,56 +375,47 @@ class PushRegistration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the user has explicitly denied the OS notification permission.
|
/// Current OS notification permission, or null when the plugin can't tell.
|
||||||
/// Read-only (no prompt) — used by the settings UI to surface the state.
|
/// Read-only — never triggers the OS prompt.
|
||||||
static Future<bool> isOsPermissionDenied() async {
|
static Future<AuthorizationStatus?> osPermissionStatus() async {
|
||||||
try {
|
try {
|
||||||
final settings = await FirebaseMessaging.instance
|
final settings = await FirebaseMessaging.instance
|
||||||
.getNotificationSettings();
|
.getNotificationSettings();
|
||||||
return !isPermissionUsable(settings.authorizationStatus);
|
return settings.authorizationStatus;
|
||||||
} on Object {
|
} on Object {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the OS notification permission is already granted
|
/// True when the OS notification permission is already granted
|
||||||
/// (`authorized`/`provisional`). Read-only — never triggers the OS prompt.
|
/// (`authorized`/`provisional`). Read-only — never triggers the OS prompt.
|
||||||
/// Used by the cold-start/self-heal path so it registers only for devices
|
|
||||||
/// that already opted in, leaving the actual prompt to the first Talk visit.
|
|
||||||
static Future<bool> isOsPermissionGranted() async {
|
static Future<bool> isOsPermissionGranted() async {
|
||||||
try {
|
final status = await osPermissionStatus();
|
||||||
final settings = await FirebaseMessaging.instance
|
return status != null && canDisplay(status);
|
||||||
.getNotificationSettings();
|
|
||||||
return settings.authorizationStatus == AuthorizationStatus.authorized ||
|
|
||||||
settings.authorizationStatus == AuthorizationStatus.provisional;
|
|
||||||
} on Object {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the OS will actually show a visible notification in [status].
|
||||||
|
/// Stricter than [isPermissionUsable]: `notDetermined` shows nothing yet.
|
||||||
|
static bool canDisplay(AuthorizationStatus status) =>
|
||||||
|
status == AuthorizationStatus.authorized ||
|
||||||
|
status == AuthorizationStatus.provisional;
|
||||||
|
|
||||||
/// Registers this device whenever the backend advertises the push capability.
|
/// Registers this device whenever the backend advertises the push capability.
|
||||||
/// Deliberately independent of the in-app notification toggle: a user who
|
/// Deliberately independent of both the in-app notification toggle and the
|
||||||
/// turned notifications off stays registered so silent sync pushes keep
|
/// OS notification permission: silent sync pushes (deletes, chat/badge
|
||||||
/// flowing — the display is suppressed downstream via the mirrored flag (see
|
/// refresh, widget refresh) need no permission — data-only FCM on Android,
|
||||||
/// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS
|
/// `content-available` on iOS — so every device stays in sync. Whether a
|
||||||
/// notification permission is *already* granted — it never triggers the OS
|
/// visible notification is wanted is reported separately via the telemetry
|
||||||
/// prompt itself. Requesting the permission is the job of the first Talk visit
|
/// heartbeat (toggle + OS permission), from which the server picks silent
|
||||||
/// (see `maybePromptTalkNotifications`), which keeps the prompt out of the
|
/// pushes on iOS. Never triggers the OS prompt. Safe to call on every
|
||||||
/// cold-start path. Safe to call on every start — Nextcloud dedups an
|
/// start — Nextcloud dedups an unchanged registration — which also
|
||||||
/// unchanged registration — which also self-heals a device whose registration
|
/// self-heals a device whose registration was lost.
|
||||||
/// was lost.
|
|
||||||
/// Returns whether registration was actually *attempted* (all gates passed).
|
/// Returns whether registration was actually *attempted* (all gates passed).
|
||||||
/// Even a partial success persists the `general` device identifier, so the
|
/// Even a partial success persists the `general` device identifier, so the
|
||||||
/// caller re-emits telemetry on `true` to reflect the fresh registration in
|
/// caller re-emits telemetry on `true` to reflect the fresh registration in
|
||||||
/// the same session instead of lagging until the next launch.
|
/// the same session instead of lagging until the next launch.
|
||||||
static Future<bool> syncSubscription({required bool capable}) async {
|
static Future<bool> syncSubscription({required bool capable}) async {
|
||||||
if (!capable) return false;
|
if (!capable) return false;
|
||||||
if (!await isOsPermissionGranted()) {
|
|
||||||
log(
|
|
||||||
'Push: OS notification permission not granted, skipping registration',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final registration = PushRegistration();
|
final registration = PushRegistration();
|
||||||
// register() below refreshes an unchanged subscription anyway; the check
|
// register() below refreshes an unchanged subscription anyway; the check
|
||||||
// only surfaces the endpoint switch in the log for diagnosability.
|
// only surfaces the endpoint switch in the log for diagnosability.
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../session/session_manager.dart';
|
import '../session/session_manager.dart';
|
||||||
@@ -142,21 +141,11 @@ Future<PushStatusReport> collectPushStatus({
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<PushCheck> _osPermission() async {
|
Future<PushCheck> _osPermission() async {
|
||||||
try {
|
final status = await PushRegistration.osPermissionStatus();
|
||||||
final settings = await FirebaseMessaging.instance.getNotificationSettings();
|
if (status == null) return PushCheck.unknown;
|
||||||
switch (settings.authorizationStatus) {
|
if (PushRegistration.canDisplay(status)) return PushCheck.ok;
|
||||||
case AuthorizationStatus.authorized:
|
if (!PushRegistration.isPermissionUsable(status)) return PushCheck.fail;
|
||||||
case AuthorizationStatus.provisional:
|
return PushCheck.unknown;
|
||||||
return PushCheck.ok;
|
|
||||||
case AuthorizationStatus.denied:
|
|
||||||
case AuthorizationStatus.deniedPermanently:
|
|
||||||
return PushCheck.fail;
|
|
||||||
case AuthorizationStatus.notDetermined:
|
|
||||||
return PushCheck.unknown;
|
|
||||||
}
|
|
||||||
} on Object {
|
|
||||||
return PushCheck.unknown;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line in the status checklist.
|
/// One line in the status checklist.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import '../view/pages/more/roomplan/roomplan.dart';
|
|||||||
import '../view/pages/more/share/qr_share_view.dart';
|
import '../view/pages/more/share/qr_share_view.dart';
|
||||||
import '../view/pages/parent_letters/parent_letter_view.dart';
|
import '../view/pages/parent_letters/parent_letter_view.dart';
|
||||||
import '../view/pages/settings/chat_background_settings_page.dart';
|
import '../view/pages/settings/chat_background_settings_page.dart';
|
||||||
|
import '../view/pages/settings/diagnostics_page.dart';
|
||||||
import '../view/pages/settings/modules_settings_page.dart';
|
import '../view/pages/settings/modules_settings_page.dart';
|
||||||
import '../view/pages/settings/settings.dart';
|
import '../view/pages/settings/settings.dart';
|
||||||
import '../view/pages/share_intent/share_chat_picker.dart';
|
import '../view/pages/share_intent/share_chat_picker.dart';
|
||||||
@@ -40,7 +41,9 @@ import '../view/pages/ticker/ticker_page_view.dart';
|
|||||||
import '../view/pages/timetable/custom_events/custom_events_view.dart';
|
import '../view/pages/timetable/custom_events/custom_events_view.dart';
|
||||||
import '../view/pages/timetable/subject_colors/subject_colors_view.dart';
|
import '../view/pages/timetable/subject_colors/subject_colors_view.dart';
|
||||||
import '../widget/avatar_crop_page.dart';
|
import '../widget/avatar_crop_page.dart';
|
||||||
|
import '../widget/debug/bloc_storage_view.dart';
|
||||||
import '../widget/debug/cache_view.dart';
|
import '../widget/debug/cache_view.dart';
|
||||||
|
import '../widget/debug/json_viewer.dart';
|
||||||
import '../widget/file_viewer.dart';
|
import '../widget/file_viewer.dart';
|
||||||
import '../widget/large_profile_picture_view.dart';
|
import '../widget/large_profile_picture_view.dart';
|
||||||
import '../widget/user_avatar.dart';
|
import '../widget/user_avatar.dart';
|
||||||
@@ -294,8 +297,26 @@ class AppRoutes {
|
|||||||
pushScreen(context, withNavBar: false, screen: const FeedbackDialog());
|
pushScreen(context, withNavBar: false, screen: const FeedbackDialog());
|
||||||
}
|
}
|
||||||
|
|
||||||
static void openCacheView(BuildContext context) {
|
static Future<void> openCacheView(BuildContext context) =>
|
||||||
pushScreen(context, withNavBar: false, screen: const CacheView());
|
pushScreen(context, withNavBar: false, screen: const CacheView());
|
||||||
|
|
||||||
|
static Future<void> openBlocStorageView(BuildContext context) =>
|
||||||
|
pushScreen(context, withNavBar: false, screen: const BlocStorageView());
|
||||||
|
|
||||||
|
static void openDiagnostics(BuildContext context) {
|
||||||
|
pushScreen(context, withNavBar: false, screen: const DiagnosticsPage());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void openJsonViewer(
|
||||||
|
BuildContext context,
|
||||||
|
Object? data, {
|
||||||
|
String? title,
|
||||||
|
}) {
|
||||||
|
pushScreen(
|
||||||
|
context,
|
||||||
|
withNavBar: false,
|
||||||
|
screen: JsonViewer(data: data, title: title ?? 'Rohdaten'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void openRoomplan(BuildContext context) {
|
static void openRoomplan(BuildContext context) {
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
|||||||
import '../api/marianumconnect/queries/auth_me/auth_me.dart';
|
import '../api/marianumconnect/queries/auth_me/auth_me.dart';
|
||||||
import '../auth_link/guardian_link_listener.dart';
|
import '../auth_link/guardian_link_listener.dart';
|
||||||
import '../background/widget_background_task.dart';
|
import '../background/widget_background_task.dart';
|
||||||
import '../push/chat_thread_store.dart';
|
import '../model/session_wipe.dart';
|
||||||
import '../push/nid_store.dart';
|
|
||||||
import '../push/push_registration.dart';
|
import '../push/push_registration.dart';
|
||||||
import '../storage/account_storage.dart';
|
import '../storage/account_storage.dart';
|
||||||
import '../widget_data/widget_sync.dart';
|
import '../widget_data/widget_sync.dart';
|
||||||
@@ -41,10 +40,11 @@ abstract final class SessionLifecycle {
|
|||||||
}
|
}
|
||||||
await AuthLogout().run();
|
await AuthLogout().run();
|
||||||
final removed = await SessionManager().signOut();
|
final removed = await SessionManager().signOut();
|
||||||
|
SessionWipe.clearImmediate();
|
||||||
// A login link that arrived while signed in must not be replayed on the
|
// A login link that arrived while signed in must not be replayed on the
|
||||||
// login screen that follows.
|
// login screen that follows.
|
||||||
GuardianLinkListener.clear();
|
GuardianLinkListener.clear();
|
||||||
await _clearPushStores();
|
await SessionWipe.accountLeft();
|
||||||
if (removed != null) await AccountStorage.delete(removed.namespace);
|
if (removed != null) await AccountStorage.delete(removed.namespace);
|
||||||
|
|
||||||
for (
|
for (
|
||||||
@@ -78,14 +78,22 @@ abstract final class SessionLifecycle {
|
|||||||
log('Switch: push deactivation failed: $e');
|
log('Switch: push deactivation failed: $e');
|
||||||
}
|
}
|
||||||
await SessionManager().stashActive();
|
await SessionManager().stashActive();
|
||||||
|
SessionWipe.clearImmediate();
|
||||||
|
var restored = false;
|
||||||
try {
|
try {
|
||||||
await SessionManager().activate(id);
|
await SessionManager().activate(id);
|
||||||
} on Object {
|
} on Object {
|
||||||
if (previous != null) await SessionManager().activate(previous.id);
|
if (previous != null) {
|
||||||
|
await SessionManager().activate(previous.id);
|
||||||
|
restored = true;
|
||||||
|
}
|
||||||
rethrow;
|
rethrow;
|
||||||
} finally {
|
} finally {
|
||||||
await _clearPushStores();
|
await SessionWipe.accountLeft();
|
||||||
await AccountStorage.activate(SessionManager().activeAccount);
|
await AccountStorage.activate(SessionManager().activeAccount);
|
||||||
|
// Nothing remounts for the account that stays, so its push would
|
||||||
|
// remain unregistered until the next start.
|
||||||
|
if (restored) unawaited(PushRegistration().register());
|
||||||
}
|
}
|
||||||
await _resetWidget();
|
await _resetWidget();
|
||||||
}
|
}
|
||||||
@@ -94,6 +102,10 @@ abstract final class SessionLifecycle {
|
|||||||
static Future<void> beginAddAccount() async {
|
static Future<void> beginAddAccount() async {
|
||||||
await SessionManager().stashActive();
|
await SessionManager().stashActive();
|
||||||
_addReturnId = SessionManager().activeAccount?.id;
|
_addReturnId = SessionManager().activeAccount?.id;
|
||||||
|
SessionWipe.clearImmediate();
|
||||||
|
// Whatever runs behind the login screen must not write into the parked
|
||||||
|
// account's data.
|
||||||
|
await AccountStorage.activate(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool get isAddingAccount => _addReturnId != null;
|
static bool get isAddingAccount => _addReturnId != null;
|
||||||
@@ -109,6 +121,7 @@ abstract final class SessionLifecycle {
|
|||||||
await SessionManager().signOut();
|
await SessionManager().signOut();
|
||||||
}
|
}
|
||||||
await SessionManager().activate(returnId);
|
await SessionManager().activate(returnId);
|
||||||
|
await AccountStorage.activate(SessionManager().activeAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called once a login finished. After "add account" the previous account's
|
/// Called once a login finished. After "add account" the previous account's
|
||||||
@@ -127,7 +140,7 @@ abstract final class SessionLifecycle {
|
|||||||
log('Add account: push deactivation failed: $e');
|
log('Add account: push deactivation failed: $e');
|
||||||
}
|
}
|
||||||
await SessionManager().activate(added.id);
|
await SessionManager().activate(added.id);
|
||||||
await _clearPushStores();
|
await SessionWipe.accountLeft();
|
||||||
}
|
}
|
||||||
await AccountStorage.activate(SessionManager().activeAccount);
|
await AccountStorage.activate(SessionManager().activeAccount);
|
||||||
return added.id;
|
return added.id;
|
||||||
@@ -191,14 +204,4 @@ abstract final class SessionLifecycle {
|
|||||||
log('Widget reset failed: $e');
|
log('Widget reset failed: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tray bookkeeping of the previous account's pushes.
|
|
||||||
static Future<void> _clearPushStores() async {
|
|
||||||
try {
|
|
||||||
await NidStore().clear();
|
|
||||||
await ChatThreadStore().clearAll();
|
|
||||||
} on Object catch (e) {
|
|
||||||
log('Push store cleanup failed: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,21 @@ class SessionManager {
|
|||||||
|
|
||||||
Session? get current => _current;
|
Session? get current => _current;
|
||||||
|
|
||||||
|
int _sessionEpoch = 0;
|
||||||
|
|
||||||
|
/// Bumped whenever the active account changes (sign-out, switch, another
|
||||||
|
/// account signing in). Async work captures it when it starts and drops its
|
||||||
|
/// result when it changed meanwhile, so a request of the previous account
|
||||||
|
/// cannot land in the next account's state or cache.
|
||||||
|
int get sessionEpoch => _sessionEpoch;
|
||||||
|
|
||||||
|
bool isCurrentSession(int epoch) => epoch == _sessionEpoch;
|
||||||
|
|
||||||
|
bool _isUiEngine = false;
|
||||||
|
|
||||||
|
/// Called from `main()`; background entry points never run it.
|
||||||
|
void markUiEngine() => _isUiEngine = true;
|
||||||
|
|
||||||
/// Every signed-in account and which one is active.
|
/// Every signed-in account and which one is active.
|
||||||
final ValueNotifier<AccountIndex> accounts = ValueNotifier(
|
final ValueNotifier<AccountIndex> accounts = ValueNotifier(
|
||||||
AccountIndex.empty,
|
AccountIndex.empty,
|
||||||
@@ -115,6 +130,7 @@ class SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _writeActive(Session session) async {
|
Future<void> _writeActive(Session session) async {
|
||||||
|
if (!_isSameAccount(_current, session)) _sessionEpoch++;
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
|
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
|
||||||
_writeSecret(key, value),
|
_writeSecret(key, value),
|
||||||
@@ -134,6 +150,7 @@ class SessionManager {
|
|||||||
/// stay in their vaults; see [activate].
|
/// stay in their vaults; see [activate].
|
||||||
Future<AccountEntry?> signOut() async {
|
Future<AccountEntry?> signOut() async {
|
||||||
final removed = activeAccount;
|
final removed = activeAccount;
|
||||||
|
_sessionEpoch++;
|
||||||
_loaded = Completer();
|
_loaded = Completer();
|
||||||
_current = null;
|
_current = null;
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
@@ -193,6 +210,11 @@ class SessionManager {
|
|||||||
await _saveIndex(accounts.value.remove(id));
|
await _saveIndex(accounts.value.remove(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _isSameAccount(Session? a, Session? b) {
|
||||||
|
if (a == null || b == null) return a == b;
|
||||||
|
return identityOf(a) == identityOf(b) && a.isDemo == b.isDemo;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveIndex(AccountIndex index) async {
|
Future<void> _saveIndex(AccountIndex index) async {
|
||||||
accounts.value = index;
|
accounts.value = index;
|
||||||
await _secureStorage.write(key: _indexKey, value: index.encode());
|
await _secureStorage.write(key: _indexKey, value: index.encode());
|
||||||
@@ -280,8 +302,15 @@ class SessionManager {
|
|||||||
Future<void> _migrateAndLoad() async {
|
Future<void> _migrateAndLoad() async {
|
||||||
await _migrateFromLegacyStorage();
|
await _migrateFromLegacyStorage();
|
||||||
await _migrateKeychainAccessibility();
|
await _migrateKeychainAccessibility();
|
||||||
// On the startup critical path (and every background wake): read in
|
_current = await _readActive();
|
||||||
// parallel instead of one keychain round-trip after the other.
|
await _loadIndex();
|
||||||
|
if (!_loaded.isCompleted) _loaded.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session in the active slots. On the startup critical path (and every
|
||||||
|
/// background wake): read in parallel instead of one keychain round-trip
|
||||||
|
/// after the other.
|
||||||
|
Future<Session?> _readActive() async {
|
||||||
final values = await Future.wait(
|
final values = await Future.wait(
|
||||||
_sessionFields.map((field) => _secureStorage.read(key: field)),
|
_sessionFields.map((field) => _secureStorage.read(key: field)),
|
||||||
);
|
);
|
||||||
@@ -296,9 +325,35 @@ class SessionManager {
|
|||||||
} on Object {
|
} on Object {
|
||||||
// Group keystore unavailable: fall back to the real password.
|
// Group keystore unavailable: fall back to the real password.
|
||||||
}
|
}
|
||||||
_current = decodeSession(raw);
|
return decodeSession(raw);
|
||||||
await _loadIndex();
|
}
|
||||||
if (!_loaded.isCompleted) _loaded.complete();
|
|
||||||
|
/// Username currently in the active slots. Other engines (widget task, push
|
||||||
|
/// isolates) sign out, in or switch without this instance noticing.
|
||||||
|
Future<String?> readStoredUsername() =>
|
||||||
|
_secureStorage.read(key: SessionKeys.username);
|
||||||
|
|
||||||
|
/// Re-reads the session for long-lived background engines: they load once
|
||||||
|
/// and would otherwise keep acting with an account that signed out or was
|
||||||
|
/// switched away from in the app meanwhile. Keeps the known state when the
|
||||||
|
/// keystore is unreadable.
|
||||||
|
Future<void> reloadFromStorage() async {
|
||||||
|
// The UI engine performs sign-in, sign-out and switches itself, so its
|
||||||
|
// state is current; re-reading in between could resurrect the removed
|
||||||
|
// account.
|
||||||
|
if (_isUiEngine) return;
|
||||||
|
try {
|
||||||
|
final session = await _readActive();
|
||||||
|
final index = AccountIndex.decode(
|
||||||
|
await _secureStorage.read(key: _indexKey),
|
||||||
|
);
|
||||||
|
if (!_isSameAccount(_current, session)) _sessionEpoch++;
|
||||||
|
_current = session;
|
||||||
|
accounts.value = index;
|
||||||
|
if (!_loaded.isCompleted) _loaded.complete();
|
||||||
|
} on Object catch (e) {
|
||||||
|
log('Session reload failed, keeping loaded state: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadIndex() async {
|
Future<void> _loadIndex() async {
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ class PendingShare {
|
|||||||
final String? text;
|
final String? text;
|
||||||
final DateTime receivedAt;
|
final DateTime receivedAt;
|
||||||
|
|
||||||
|
/// Paths as delivered by the platform, before the listener moved them into
|
||||||
|
/// a per-share folder. Duplicate detection compares these, since the
|
||||||
|
/// relocated [filePaths] differ for every delivery.
|
||||||
|
final List<String> sourcePaths;
|
||||||
|
|
||||||
const PendingShare({
|
const PendingShare({
|
||||||
required this.filePaths,
|
required this.filePaths,
|
||||||
required this.text,
|
required this.text,
|
||||||
required this.receivedAt,
|
required this.receivedAt,
|
||||||
});
|
List<String>? sourcePaths,
|
||||||
|
}) : sourcePaths = sourcePaths ?? filePaths;
|
||||||
|
|
||||||
bool get hasFiles => filePaths.isNotEmpty;
|
bool get hasFiles => filePaths.isNotEmpty;
|
||||||
bool get hasText => text != null && text!.isNotEmpty;
|
bool get hasText => text != null && text!.isNotEmpty;
|
||||||
@@ -20,5 +26,5 @@ class PendingShare {
|
|||||||
/// the same share can arrive twice on the media stream — receivedAt is
|
/// the same share can arrive twice on the media stream — receivedAt is
|
||||||
/// deliberately ignored here so such duplicates compare equal.
|
/// deliberately ignored here so such duplicates compare equal.
|
||||||
bool contentEquals(PendingShare other) =>
|
bool contentEquals(PendingShare other) =>
|
||||||
text == other.text && listEquals(filePaths, other.filePaths);
|
text == other.text && listEquals(sourcePaths, other.sourcePaths);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||||
|
|
||||||
import '../auth_link/guardian_login_link.dart';
|
import '../auth_link/guardian_login_link.dart';
|
||||||
@@ -15,55 +17,63 @@ class ShareIntentListener {
|
|||||||
|
|
||||||
static final ValueNotifier<PendingShare?> pending = ValueNotifier(null);
|
static final ValueNotifier<PendingShare?> pending = ValueNotifier(null);
|
||||||
|
|
||||||
StreamSubscription<List<SharedMediaFile>>? _streamSub;
|
static const _androidChannel = MethodChannel('eu.mhsl.marianum.share');
|
||||||
bool _initialized = false;
|
|
||||||
|
|
||||||
/// Reads the cold-start payload exactly once. Call from `main()` before
|
/// Per-share folders live below this directory name — on Android in the
|
||||||
/// `runApp` so the share is queued before the UI mounts.
|
/// cache dir (created here), on iOS in the App Group container (created by
|
||||||
|
/// the Share Extension).
|
||||||
|
static const _shareDirName = 'share_intent';
|
||||||
|
|
||||||
|
bool _initialized = false;
|
||||||
|
String? _cacheDir;
|
||||||
|
|
||||||
|
int _flowDepth = 0;
|
||||||
|
PendingShare? _queued;
|
||||||
|
PendingShare? _shown;
|
||||||
|
|
||||||
|
/// Subscribes to warm shares and reads the cold-start payload. Call from
|
||||||
|
/// `main()` before `runApp`. The subscription stays for the process
|
||||||
|
/// lifetime: the plugin drops shares that arrive while nobody listens
|
||||||
|
/// (login screen, app remount), and [pending] buffers until the UI is ready.
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
try {
|
try {
|
||||||
final initial = await ReceiveSharingIntent.instance.getInitialMedia();
|
if (Platform.isAndroid) {
|
||||||
final share = _toPendingShare(initial);
|
_cacheDir = (await getTemporaryDirectory()).path;
|
||||||
if (share != null) _publish(share);
|
_sweepStaleAndroidShares();
|
||||||
|
}
|
||||||
|
ReceiveSharingIntent.instance.getMediaStream().listen(
|
||||||
|
_onItems,
|
||||||
|
onError: (Object e) => debugPrint('ShareIntentListener stream: $e'),
|
||||||
|
);
|
||||||
|
_onItems(await ReceiveSharingIntent.instance.getInitialMedia());
|
||||||
await ReceiveSharingIntent.instance.reset();
|
await ReceiveSharingIntent.instance.reset();
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
await _androidChannel.invokeMethod<void>('listenerReady');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('ShareIntentListener.initialize failed: $e');
|
debugPrint('ShareIntentListener.initialize failed: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribes to warm-share stream events. Safe to call multiple times.
|
/// Whether the share flow for [share] is already on screen. Re-running the
|
||||||
void attach() {
|
/// navigation for it would pop its own page and thereby clear it.
|
||||||
_streamSub ??= ReceiveSharingIntent.instance.getMediaStream().listen(
|
bool isShown(PendingShare share) => identical(share, _shown);
|
||||||
(items) {
|
|
||||||
final share = _toPendingShare(items);
|
void markShown(PendingShare share) => _shown = share;
|
||||||
if (share != null) _publish(share);
|
|
||||||
},
|
/// Marks a share flow step that must not be interrupted (upload, share API
|
||||||
onError: (Object e) => debugPrint('ShareIntentListener stream error: $e'),
|
/// call). Shares arriving meanwhile are queued instead of replacing the
|
||||||
);
|
/// current one, whose files are still in use. Pair with [endFlow].
|
||||||
|
void beginFlow() => _flowDepth++;
|
||||||
|
|
||||||
|
void endFlow() {
|
||||||
|
if (_flowDepth > 0) _flowDepth--;
|
||||||
|
if (_flowDepth == 0) _drainQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The iOS Share Extension fires two `open(url)` requests per share, so the
|
/// Discards the current share and deletes its temp files. Idempotent.
|
||||||
/// same payload can arrive twice in quick succession. Publishing the
|
|
||||||
/// duplicate would re-trigger the share-flow navigation, pop the already
|
|
||||||
/// open ShareTargetPage and thereby delete the temp files of the share that
|
|
||||||
/// is still in flight — swallow it instead.
|
|
||||||
void _publish(PendingShare share) {
|
|
||||||
final current = pending.value;
|
|
||||||
if (current != null && current.contentEquals(share)) return;
|
|
||||||
pending.value = share;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancels the warm-share subscription. The singleton survives, so a
|
|
||||||
/// subsequent [attach] re-subscribes.
|
|
||||||
void detach() {
|
|
||||||
_streamSub?.cancel();
|
|
||||||
_streamSub = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Discards the current share and removes any temp files the plugin copied
|
|
||||||
/// into the app cache. Idempotent.
|
|
||||||
///
|
///
|
||||||
/// Pass [ifCurrent] from UI that owns a specific share (e.g. the
|
/// Pass [ifCurrent] from UI that owns a specific share (e.g. the
|
||||||
/// ShareTargetPage pop handler): the call then only acts while that share
|
/// ShareTargetPage pop handler): the call then only acts while that share
|
||||||
@@ -73,18 +83,58 @@ class ShareIntentListener {
|
|||||||
void clear({PendingShare? ifCurrent}) {
|
void clear({PendingShare? ifCurrent}) {
|
||||||
final current = pending.value;
|
final current = pending.value;
|
||||||
if (ifCurrent != null && !identical(current, ifCurrent)) return;
|
if (ifCurrent != null && !identical(current, ifCurrent)) return;
|
||||||
|
_shown = null;
|
||||||
pending.value = null;
|
pending.value = null;
|
||||||
if (current != null) {
|
if (current != null) _deleteFiles(current);
|
||||||
for (final path in current.filePaths) {
|
unawaited(ReceiveSharingIntent.instance.reset());
|
||||||
try {
|
if (_flowDepth == 0) _drainQueue();
|
||||||
final f = File(path);
|
}
|
||||||
if (f.existsSync()) f.deleteSync();
|
|
||||||
} catch (_) {
|
/// Drops every share, including a queued one — for sign-out.
|
||||||
// best-effort cleanup; OS will reclaim cache eventually
|
void clearAll() {
|
||||||
}
|
final queued = _queued;
|
||||||
|
_queued = null;
|
||||||
|
_flowDepth = 0;
|
||||||
|
if (queued != null) _deleteFiles(queued);
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItems(List<SharedMediaFile> items) {
|
||||||
|
final share = _toPendingShare(items);
|
||||||
|
if (share == null) return;
|
||||||
|
// The iOS Share Extension opens the app twice per share, so the same
|
||||||
|
// payload can arrive again while it is still pending. Publishing it would
|
||||||
|
// re-run the navigation and pop the share that is in flight.
|
||||||
|
for (final live in [pending.value, _queued]) {
|
||||||
|
if (live != null && live.contentEquals(share)) {
|
||||||
|
_deleteFiles(share, keep: live.filePaths);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unawaited(ReceiveSharingIntent.instance.reset());
|
final claimed = _claim(share);
|
||||||
|
if (_flowDepth > 0) {
|
||||||
|
final replaced = _queued;
|
||||||
|
_queued = claimed;
|
||||||
|
if (replaced != null) _deleteFiles(replaced);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_replaceCurrent(claimed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drainQueue() {
|
||||||
|
final queued = _queued;
|
||||||
|
if (queued == null) return;
|
||||||
|
_queued = null;
|
||||||
|
_replaceCurrent(queued);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No flow is running, so nothing uses the previous share's files anymore;
|
||||||
|
// its pages are popped when the new share is routed.
|
||||||
|
void _replaceCurrent(PendingShare share) {
|
||||||
|
final previous = pending.value;
|
||||||
|
_shown = null;
|
||||||
|
pending.value = share;
|
||||||
|
if (previous != null) _deleteFiles(previous);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Android hands an opened App Link (guardian login mail) to the share
|
/// Android hands an opened App Link (guardian login mail) to the share
|
||||||
@@ -106,7 +156,9 @@ class ShareIntentListener {
|
|||||||
case SharedMediaType.image:
|
case SharedMediaType.image:
|
||||||
case SharedMediaType.video:
|
case SharedMediaType.video:
|
||||||
case SharedMediaType.file:
|
case SharedMediaType.file:
|
||||||
files.add(item.path);
|
// A replayed share can point at temp copies deleted after the
|
||||||
|
// original share completed.
|
||||||
|
if (File(item.path).existsSync()) files.add(item.path);
|
||||||
case SharedMediaType.text:
|
case SharedMediaType.text:
|
||||||
case SharedMediaType.url:
|
case SharedMediaType.url:
|
||||||
if (!isAppLink(item.path)) texts.add(item.path);
|
if (!isAppLink(item.path)) texts.add(item.path);
|
||||||
@@ -119,4 +171,77 @@ class ShareIntentListener {
|
|||||||
receivedAt: DateTime.now(),
|
receivedAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Android plugin copies shared content to `cacheDir/<display name>`,
|
||||||
|
/// so a second share with an equally named file would overwrite the first
|
||||||
|
/// one's copy. Move owned copies into a folder of their own. iOS shares
|
||||||
|
/// already arrive in a per-share folder.
|
||||||
|
PendingShare _claim(PendingShare share) {
|
||||||
|
final cacheDir = _cacheDir;
|
||||||
|
if (!Platform.isAndroid || cacheDir == null || !share.hasFiles) {
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
final dir = Directory(
|
||||||
|
'$cacheDir/$_shareDirName/${share.receivedAt.microsecondsSinceEpoch}',
|
||||||
|
);
|
||||||
|
final claimed = <String>[];
|
||||||
|
for (final (i, path) in share.filePaths.indexed) {
|
||||||
|
if (!_isOwned(path)) {
|
||||||
|
claimed.add(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
dir.createSync(recursive: true);
|
||||||
|
final name = path.split(Platform.pathSeparator).last;
|
||||||
|
var target = '${dir.path}/$name';
|
||||||
|
if (File(target).existsSync()) target = '${dir.path}/$i-$name';
|
||||||
|
claimed.add(File(path).renameSync(target).path);
|
||||||
|
} catch (_) {
|
||||||
|
claimed.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PendingShare(
|
||||||
|
filePaths: claimed,
|
||||||
|
text: share.text,
|
||||||
|
receivedAt: share.receivedAt,
|
||||||
|
sourcePaths: share.sourcePaths,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only temp copies are deleted: on Android the plugin hands out the real
|
||||||
|
/// path for some providers (e.g. a file in Downloads), which must survive.
|
||||||
|
bool _isOwned(String path) {
|
||||||
|
if (Platform.isIOS) return true;
|
||||||
|
final cacheDir = _cacheDir;
|
||||||
|
return cacheDir != null && path.startsWith('$cacheDir/');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteFiles(PendingShare share, {List<String> keep = const []}) {
|
||||||
|
for (final path in share.filePaths) {
|
||||||
|
if (keep.contains(path) || !_isOwned(path)) continue;
|
||||||
|
try {
|
||||||
|
final file = File(path);
|
||||||
|
if (file.existsSync()) file.deleteSync();
|
||||||
|
final dir = file.parent;
|
||||||
|
if (dir.parent.path.endsWith('/$_shareDirName') &&
|
||||||
|
dir.listSync().isEmpty) {
|
||||||
|
dir.deleteSync();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// best-effort cleanup; OS will reclaim cache eventually
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shares whose flow never finished (process killed) leave their folder
|
||||||
|
// behind; the pending one of this process is not claimed yet.
|
||||||
|
void _sweepStaleAndroidShares() {
|
||||||
|
try {
|
||||||
|
final root = Directory('$_cacheDir/$_shareDirName');
|
||||||
|
if (!root.existsSync()) return;
|
||||||
|
root.deleteSync(recursive: true);
|
||||||
|
} catch (_) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,15 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
|
|||||||
|
|
||||||
LoadableStateBloc() : super(const LoadableStateState(connections: null)) {
|
LoadableStateBloc() : super(const LoadableStateState(connections: null)) {
|
||||||
on<ConnectivityChanged>((event, emit) {
|
on<ConnectivityChanged>((event, emit) {
|
||||||
|
// Only a real reconnect (or a resume) warrants a refetch: the initial
|
||||||
|
// status after mount and Wi-Fi ↔ mobile handovers would otherwise
|
||||||
|
// reload an already loaded page for nothing.
|
||||||
|
final wasOffline = connectivityStatusKnown() && !isConnected();
|
||||||
emit(event.state);
|
emit(event.state);
|
||||||
if (connectivityStatusKnown() && isConnected()) {
|
if ((wasOffline || event.fromResume) &&
|
||||||
if (reFetch == null) return;
|
connectivityStatusKnown() &&
|
||||||
reFetch!();
|
isConnected()) {
|
||||||
|
reFetch?.call();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,7 +59,12 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
|
|||||||
unawaited(
|
unawaited(
|
||||||
Connectivity().checkConnectivity().then((result) {
|
Connectivity().checkConnectivity().then((result) {
|
||||||
if (isClosed) return;
|
if (isClosed) return;
|
||||||
add(ConnectivityChanged(LoadableStateState(connections: result)));
|
add(
|
||||||
|
ConnectivityChanged(
|
||||||
|
LoadableStateState(connections: result),
|
||||||
|
fromResume: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,10 @@ sealed class LoadableStateEvent {}
|
|||||||
|
|
||||||
final class ConnectivityChanged extends LoadableStateEvent {
|
final class ConnectivityChanged extends LoadableStateEvent {
|
||||||
final LoadableStateState state;
|
final LoadableStateState state;
|
||||||
ConnectivityChanged(this.state);
|
|
||||||
|
/// Re-check after the app came back to the foreground: refetches whenever
|
||||||
|
/// online, not only on an offline → online transition.
|
||||||
|
final bool fromResume;
|
||||||
|
|
||||||
|
ConnectivityChanged(this.state, {this.fromResume = false});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class LoadableStateConsumer<
|
|||||||
// null mid-refetch, and toggling the RefreshIndicator on that signal would
|
// null mid-refetch, and toggling the RefreshIndicator on that signal would
|
||||||
// rebuild the tree under the ListView and reset its scroll position.
|
// rebuild the tree under the ListView and reset its scroll position.
|
||||||
final content = SizedBox(
|
final content = SizedBox(
|
||||||
height: MediaQuery.of(context).size.height,
|
height: MediaQuery.sizeOf(context).height,
|
||||||
child: hasContent
|
child: hasContent
|
||||||
? child(typedData as TState, isLoading)
|
? child(typedData as TState, isLoading)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
|
|||||||
@@ -95,10 +95,12 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
|
|||||||
late Timer _rebuildTimer;
|
late Timer _rebuildTimer;
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
_rebuildTimer = Timer.periodic(
|
// Only refresh the relative "last updated" text while this page is the
|
||||||
const Duration(seconds: 10),
|
// visible one; offstage tabs and covered routes have tickers disabled.
|
||||||
(timer) => setState(() {}),
|
_rebuildTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
|
||||||
);
|
if (!mounted) return;
|
||||||
|
if (TickerMode.getValuesNotifier(context).value.enabled) setState(() {});
|
||||||
|
});
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-32
@@ -33,6 +33,9 @@ class _LoadableStatePrimaryLoadingState
|
|||||||
extends State<LoadableStatePrimaryLoading> {
|
extends State<LoadableStatePrimaryLoading> {
|
||||||
Timer? _slowHintTimer;
|
Timer? _slowHintTimer;
|
||||||
bool _showSlowHint = false;
|
bool _showSlowHint = false;
|
||||||
|
// An indeterminate spinner ticks every frame even at opacity 0, so it is
|
||||||
|
// unmounted once the fade-out finished instead of just being hidden.
|
||||||
|
late bool _spinnerMounted = widget.visible;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -44,6 +47,7 @@ class _LoadableStatePrimaryLoadingState
|
|||||||
void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) {
|
void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (widget.visible != oldWidget.visible) _restartSlowHintTimer();
|
if (widget.visible != oldWidget.visible) _restartSlowHintTimer();
|
||||||
|
if (widget.visible) _spinnerMounted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _restartSlowHintTimer() {
|
void _restartSlowHintTimer() {
|
||||||
@@ -62,43 +66,49 @@ class _LoadableStatePrimaryLoadingState
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => AnimatedOpacity(
|
||||||
|
opacity: widget.visible ? 1.0 : 0.0,
|
||||||
|
duration: LoadableStateConsumer.animationDuration,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
onEnd: () {
|
||||||
|
if (!widget.visible && _spinnerMounted) {
|
||||||
|
setState(() => _spinnerMounted = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: _spinnerMounted ? _spinner(context) : const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _spinner(BuildContext context) {
|
||||||
final status =
|
final status =
|
||||||
widget.statusText ??
|
widget.statusText ??
|
||||||
(_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null);
|
(_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null);
|
||||||
|
return Center(
|
||||||
return AnimatedOpacity(
|
child: Column(
|
||||||
opacity: widget.visible ? 1.0 : 0.0,
|
mainAxisSize: MainAxisSize.min,
|
||||||
duration: LoadableStateConsumer.animationDuration,
|
children: [
|
||||||
curve: Curves.easeInOut,
|
const AppProgressIndicator.large(),
|
||||||
child: Center(
|
AnimatedSwitcher(
|
||||||
child: Column(
|
duration: LoadableStateConsumer.animationDuration,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: status == null
|
||||||
children: [
|
? const SizedBox.shrink()
|
||||||
const AppProgressIndicator.large(),
|
: Padding(
|
||||||
AnimatedSwitcher(
|
key: ValueKey(status),
|
||||||
duration: LoadableStateConsumer.animationDuration,
|
padding: const EdgeInsets.only(
|
||||||
child: status == null
|
top: 16,
|
||||||
? const SizedBox.shrink()
|
left: 24,
|
||||||
: Padding(
|
right: 24,
|
||||||
key: ValueKey(status),
|
),
|
||||||
padding: const EdgeInsets.only(
|
child: Text(
|
||||||
top: 16,
|
status,
|
||||||
left: 24,
|
textAlign: TextAlign.center,
|
||||||
right: 24,
|
style: TextStyle(
|
||||||
),
|
fontSize: 13,
|
||||||
child: Text(
|
color: Theme.of(context).hintColor,
|
||||||
status,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: Theme.of(context).hintColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-22
@@ -1,10 +1,13 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||||
|
|
||||||
import '../../../../../access/access_requirement.dart';
|
import '../../../../../access/access_requirement.dart';
|
||||||
import '../../../../../api/errors/error_mapper.dart';
|
import '../../../../../api/errors/error_mapper.dart';
|
||||||
|
import '../../../../../api/errors/stale_session_exception.dart';
|
||||||
import '../../../../../session/session_manager.dart';
|
import '../../../../../session/session_manager.dart';
|
||||||
|
import '../../../../../utils/session_single_flight.dart';
|
||||||
import '../../loadable_state/loadable_state.dart';
|
import '../../loadable_state/loadable_state.dart';
|
||||||
import '../../loadable_state/loading_error.dart';
|
import '../../loadable_state/loading_error.dart';
|
||||||
import '../../repository/repository.dart';
|
import '../../repository/repository.dart';
|
||||||
@@ -19,6 +22,12 @@ abstract class LoadableHydratedBloc<
|
|||||||
extends
|
extends
|
||||||
HydratedBloc<LoadableHydratedBlocEvent<TState>, LoadableState<TState>> {
|
HydratedBloc<LoadableHydratedBlocEvent<TState>, LoadableState<TState>> {
|
||||||
late TRepository _repository;
|
late TRepository _repository;
|
||||||
|
|
||||||
|
// HydratedBloc serialises and writes the full state on every emit; loading
|
||||||
|
// flags, status texts and errors re-emit the very same data, so those
|
||||||
|
// writes are skipped (see [persistenceKey]).
|
||||||
|
Object? _lastPersistedKey;
|
||||||
|
int? _lastPersistedFetch;
|
||||||
LoadableHydratedBloc()
|
LoadableHydratedBloc()
|
||||||
: super(
|
: super(
|
||||||
const LoadableState(
|
const LoadableState(
|
||||||
@@ -112,6 +121,8 @@ abstract class LoadableHydratedBloc<
|
|||||||
/// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user
|
/// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user
|
||||||
/// is authenticated again, otherwise the UI would stay blank.
|
/// is authenticated again, otherwise the UI would stay blank.
|
||||||
Future<void> reset() async {
|
Future<void> reset() async {
|
||||||
|
_lastPersistedKey = null;
|
||||||
|
_lastPersistedFetch = null;
|
||||||
await clear();
|
await clear();
|
||||||
add(Reset<TState>());
|
add(Reset<TState>());
|
||||||
}
|
}
|
||||||
@@ -123,6 +134,30 @@ abstract class LoadableHydratedBloc<
|
|||||||
|
|
||||||
bool get requirementsMet => requirements.areMetBy(SessionManager().current);
|
bool get requirementsMet => requirements.areMetBy(SessionManager().current);
|
||||||
|
|
||||||
|
// Blocs are rebuilt per account (see AccountScope). One that briefly
|
||||||
|
// outlives its account would otherwise load with the next account's
|
||||||
|
// credentials and persist the result into its own storage.
|
||||||
|
final int _ownSession = SessionManager().sessionEpoch;
|
||||||
|
|
||||||
|
/// Whether the account this bloc was created for is still the active one.
|
||||||
|
bool get ownsSession => SessionManager().isCurrentSession(_ownSession);
|
||||||
|
|
||||||
|
static const _sessionKey = #loadableSessionEpoch;
|
||||||
|
|
||||||
|
/// Runs [body] tagged with the current session: events it adds, also from
|
||||||
|
/// its async continuations, are dropped once the account signed out, so a
|
||||||
|
/// late response of the previous account cannot refill the reset bloc.
|
||||||
|
R runInSession<R>(R Function() body) =>
|
||||||
|
runZoned(body, zoneValues: {_sessionKey: SessionManager().sessionEpoch});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void add(LoadableHydratedBlocEvent<TState> event) {
|
||||||
|
if (!ownsSession) return;
|
||||||
|
final epoch = Zone.current[_sessionKey];
|
||||||
|
if (epoch is int && !SessionManager().isCurrentSession(epoch)) return;
|
||||||
|
super.add(event);
|
||||||
|
}
|
||||||
|
|
||||||
TState? get innerState => state.data;
|
TState? get innerState => state.data;
|
||||||
TRepository get repo => _repository;
|
TRepository get repo => _repository;
|
||||||
|
|
||||||
@@ -135,30 +170,47 @@ abstract class LoadableHydratedBloc<
|
|||||||
/// Maps [e] through the shared error mapper and emits it as an [Error] event.
|
/// Maps [e] through the shared error mapper and emits it as an [Error] event.
|
||||||
/// Does not guard [isClosed] — callers decide whether a late error still
|
/// Does not guard [isClosed] — callers decide whether a late error still
|
||||||
/// applies.
|
/// applies.
|
||||||
void addLoadingError(Object e) => add(
|
void addLoadingError(Object e) {
|
||||||
Error(
|
// Belongs to a signed-out account; the current one loads on its own.
|
||||||
LoadingError(
|
if (e is StaleSessionException) return;
|
||||||
message: errorToUserMessage(e),
|
add(
|
||||||
technicalDetails: errorToTechnicalDetails(e),
|
Error(
|
||||||
allowRetry: errorAllowsRetry(e),
|
LoadingError(
|
||||||
|
message: errorToUserMessage(e),
|
||||||
|
technicalDetails: errorToTechnicalDetails(e),
|
||||||
|
allowRetry: errorAllowsRetry(e),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
// The constructor, the app shell and resume/reconnect handlers can all ask
|
||||||
|
// at once; each parallel gather would re-parse, re-emit and re-persist the
|
||||||
|
// same data.
|
||||||
|
final SessionSingleFlight _fetchFlight = SessionSingleFlight();
|
||||||
|
|
||||||
void fetch() {
|
void fetch() {
|
||||||
if (!requirementsMet) return;
|
if (!requirementsMet || !ownsSession) return;
|
||||||
log('Fetching data for ${TState.toString()}');
|
unawaited(
|
||||||
gatherData()
|
_fetchFlight.run(() {
|
||||||
.catchError((Object e) {
|
log('Fetching data for ${TState.toString()}');
|
||||||
log('Error while fetching ${TState.toString()}: ${e.toString()}');
|
return runInSession(
|
||||||
// The bloc may have been closed before this async error landed;
|
() => gatherData()
|
||||||
// adding to a closed bloc throws, so swallow that case.
|
.catchError((Object e) {
|
||||||
if (isClosed) return;
|
log(
|
||||||
addLoadingError(e);
|
'Error while fetching ${TState.toString()}: ${e.toString()}',
|
||||||
})
|
);
|
||||||
.then((value) {
|
// The bloc may have been closed before this async error
|
||||||
log('Fetch for ${TState.toString()} completed!');
|
// landed; adding to a closed bloc throws, so swallow that case.
|
||||||
});
|
if (isClosed) return;
|
||||||
|
addLoadingError(e);
|
||||||
|
})
|
||||||
|
.then((value) {
|
||||||
|
log('Fetch for ${TState.toString()} completed!');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -175,13 +227,27 @@ abstract class LoadableHydratedBloc<
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic>? toJson(LoadableState<TState> state) {
|
Map<String, dynamic>? toJson(LoadableState<TState> state) {
|
||||||
|
final stateData = state.data;
|
||||||
|
final key = stateData is TState ? persistenceKey(stateData) : null;
|
||||||
|
if (key != null &&
|
||||||
|
// Identity for plain data: a freezed `==` would deep-compare it.
|
||||||
|
(identical(key, _lastPersistedKey) ||
|
||||||
|
(key is Record && key == _lastPersistedKey)) &&
|
||||||
|
state.lastFetch == _lastPersistedFetch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_lastPersistedKey = key;
|
||||||
|
_lastPersistedFetch = state.lastFetch;
|
||||||
|
|
||||||
Map<String, dynamic>? data;
|
Map<String, dynamic>? data;
|
||||||
try {
|
try {
|
||||||
final stateData = state.data;
|
|
||||||
data = stateData is TState ? toStorage(stateData) : null;
|
data = stateData is TState ? toStorage(stateData) : null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('Failed to save state ${TState.toString()}: ${e.toString()}');
|
log('Failed to save state ${TState.toString()}: ${e.toString()}');
|
||||||
}
|
}
|
||||||
|
// Blocs that keep nothing on disk return null from toStorage; writing an
|
||||||
|
// empty wrapper per emit would be pure overhead.
|
||||||
|
if (stateData != null && data == null) return null;
|
||||||
|
|
||||||
return LoadableSaveContext.wrap(
|
return LoadableSaveContext.wrap(
|
||||||
data,
|
data,
|
||||||
@@ -189,6 +255,13 @@ abstract class LoadableHydratedBloc<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What has to change for the state to be written to disk again. Defaults
|
||||||
|
/// to the data instance; blocs whose state also carries transient UI flags
|
||||||
|
/// can return a record of just the persisted parts (records compare with
|
||||||
|
/// `==`, so use fields that compare cheaply, e.g. by identity).
|
||||||
|
Object? persistenceKey(TState data) => data;
|
||||||
|
|
||||||
|
|
||||||
Future<void> gatherData();
|
Future<void> gatherData();
|
||||||
TRepository repository();
|
TRepository repository();
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,27 @@ class AppModule {
|
|||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Modules that are of no use to teachers and start out hidden for them.
|
||||||
|
static const Set<Modules> hiddenForTeachersByDefault = {
|
||||||
|
Modules.absenceReport,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Hides [hiddenForTeachersByDefault] once for teacher accounts; the teacher
|
||||||
|
/// can re-enable them in the module settings. Returns whether [settings]
|
||||||
|
/// changed.
|
||||||
|
static bool applyTeacherDefaults(
|
||||||
|
ModulesSettings settings, {
|
||||||
|
required bool isTeacher,
|
||||||
|
}) {
|
||||||
|
if (!isTeacher || settings.teacherDefaultsApplied) return false;
|
||||||
|
settings.hiddenModules = {
|
||||||
|
...settings.hiddenModules,
|
||||||
|
...hiddenForTeachersByDefault,
|
||||||
|
}.toList();
|
||||||
|
settings.teacherDefaultsApplied = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// The settings list displays a capability-filtered subset, so reorder
|
// The settings list displays a capability-filtered subset, so reorder
|
||||||
// indices refer to that subset; the move is applied there and merged back
|
// indices refer to that subset; the move is applied there and merged back
|
||||||
// into the full order (non-displayed modules keep their relative slots).
|
// into the full order (non-displayed modules keep their relative slots).
|
||||||
|
|||||||
@@ -37,8 +37,12 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final epoch = SessionManager().sessionEpoch;
|
||||||
try {
|
try {
|
||||||
final response = await GetCapabilities().run();
|
final response = await GetCapabilities().run();
|
||||||
|
// A slow answer for the previous account must not decide the modules
|
||||||
|
// of the next one.
|
||||||
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
emit(
|
emit(
|
||||||
CapabilitiesState(
|
CapabilitiesState(
|
||||||
viewForeignTimetables: response.viewForeignTimetables,
|
viewForeignTimetables: response.viewForeignTimetables,
|
||||||
@@ -56,7 +60,7 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() => emit(const CapabilitiesState());
|
Future<void> reset() async => emit(const CapabilitiesState());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
CapabilitiesState fromJson(Map<String, dynamic> json) {
|
CapabilitiesState fromJson(Map<String, dynamic> json) {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class ChatBloc
|
|||||||
final ChatListBloc? _chatListBloc;
|
final ChatListBloc? _chatListBloc;
|
||||||
|
|
||||||
String? _pollingToken;
|
String? _pollingToken;
|
||||||
|
// A loop parked in its request survives stop+start for the same token
|
||||||
|
// (pause/resume); the generation retires it so only one loop polls.
|
||||||
|
int _pollGeneration = 0;
|
||||||
int _backoffMs = 0;
|
int _backoffMs = 0;
|
||||||
int _lastKnownMessageId = 0;
|
int _lastKnownMessageId = 0;
|
||||||
bool _appResumed = true;
|
bool _appResumed = true;
|
||||||
@@ -56,6 +59,12 @@ class ChatBloc
|
|||||||
@override
|
@override
|
||||||
ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json);
|
ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json);
|
||||||
|
|
||||||
|
// Loading-older and reply-reference flips must not re-persist up to 500
|
||||||
|
// messages; GetChatResponse has no `==`, so it compares by identity here.
|
||||||
|
@override
|
||||||
|
Object? persistenceKey(ChatState data) =>
|
||||||
|
(data.currentToken, data.chatResponse, data.hasMoreOld);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic>? toStorage(ChatState state) {
|
Map<String, dynamic>? toStorage(ChatState state) {
|
||||||
final response = state.chatResponse;
|
final response = state.chatResponse;
|
||||||
@@ -123,6 +132,15 @@ class ChatBloc
|
|||||||
/// No-op when the bloc has already moved on to a different token: when
|
/// No-op when the bloc has already moved on to a different token: when
|
||||||
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
|
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
|
||||||
/// before B's dispose fires.
|
/// before B's dispose fires.
|
||||||
|
/// The chat view may still be popping when the sign-out resets this bloc,
|
||||||
|
/// so leaveChat would find no token and the long-poll would keep running.
|
||||||
|
@override
|
||||||
|
Future<void> reset() {
|
||||||
|
_chatViewActive = false;
|
||||||
|
_stopLongPoll();
|
||||||
|
return super.reset();
|
||||||
|
}
|
||||||
|
|
||||||
void leaveChat(String fromToken) {
|
void leaveChat(String fromToken) {
|
||||||
if ((innerState?.currentToken ?? '') != fromToken) return;
|
if ((innerState?.currentToken ?? '') != fromToken) return;
|
||||||
_chatViewActive = false;
|
_chatViewActive = false;
|
||||||
@@ -256,23 +274,25 @@ class ChatBloc
|
|||||||
_pollingToken = token;
|
_pollingToken = token;
|
||||||
_backoffMs = 0;
|
_backoffMs = 0;
|
||||||
_lastKnownMessageId = _maxMessageId(innerState?.chatResponse);
|
_lastKnownMessageId = _maxMessageId(innerState?.chatResponse);
|
||||||
unawaited(_pollLoop(token));
|
unawaited(_pollLoop(token, ++_pollGeneration));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _stopLongPoll() {
|
void _stopLongPoll() {
|
||||||
_pollingToken = null;
|
_pollingToken = null;
|
||||||
|
_pollGeneration++;
|
||||||
_backoffMs = 0;
|
_backoffMs = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pollLoop(String token) async {
|
Future<void> _pollLoop(String token, int generation) async {
|
||||||
while (_pollingToken == token && !isClosed) {
|
bool active() => generation == _pollGeneration && !isClosed;
|
||||||
|
while (active()) {
|
||||||
try {
|
try {
|
||||||
final response = await LongPollChat(
|
final response = await LongPollChat(
|
||||||
chatToken: token,
|
chatToken: token,
|
||||||
lastKnownMessageId: _lastKnownMessageId,
|
lastKnownMessageId: _lastKnownMessageId,
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
if (_pollingToken != token || isClosed) return;
|
if (!active()) return;
|
||||||
_backoffMs = 0;
|
_backoffMs = 0;
|
||||||
|
|
||||||
if (response == null) continue;
|
if (response == null) continue;
|
||||||
@@ -296,7 +316,7 @@ class ChatBloc
|
|||||||
_chatListBloc?.markRoomAsRead(token, _lastKnownMessageId);
|
_chatListBloc?.markRoomAsRead(token, _lastKnownMessageId);
|
||||||
}
|
}
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
if (_pollingToken != token || isClosed) return;
|
if (!active()) return;
|
||||||
log('LongPoll error for $token: $e');
|
log('LongPoll error for $token: $e');
|
||||||
_backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000);
|
_backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000);
|
||||||
await Future.delayed(Duration(milliseconds: _backoffMs));
|
await Future.delayed(Duration(milliseconds: _backoffMs));
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||||
|
|
||||||
import '../../../../../access/access_requirement.dart';
|
import '../../../../../access/access_requirement.dart';
|
||||||
|
import '../../../../../api/marianumcloud/talk/actions/talk_actions.dart';
|
||||||
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
|
import '../../../../../utils/session_single_flight.dart';
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||||
import '../repository/chat_list_repository.dart';
|
import '../repository/chat_list_repository.dart';
|
||||||
@@ -31,6 +34,13 @@ class ChatListBloc
|
|||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The timer outlives the app shell; the next shell re-arms it after login.
|
||||||
|
@override
|
||||||
|
Future<void> reset() {
|
||||||
|
setAutoRefreshInterval(null);
|
||||||
|
return super.reset();
|
||||||
|
}
|
||||||
|
|
||||||
/// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent.
|
/// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent.
|
||||||
void setAutoRefreshInterval(Duration? interval) {
|
void setAutoRefreshInterval(Duration? interval) {
|
||||||
if (interval == _autoRefreshInterval) return;
|
if (interval == _autoRefreshInterval) return;
|
||||||
@@ -72,14 +82,34 @@ class ChatListBloc
|
|||||||
renew: renew,
|
renew: renew,
|
||||||
onError: (e) => capturedError = e,
|
onError: (e) => capturedError = e,
|
||||||
);
|
);
|
||||||
|
_lastRoomsJson = null;
|
||||||
add(DataGathered((s) => s.copyWith(rooms: rooms)));
|
add(DataGathered((s) => s.copyWith(rooms: rooms)));
|
||||||
_updateAppBadge(rooms);
|
_updateAppBadge(rooms);
|
||||||
|
|
||||||
if (capturedError != null) throw capturedError!;
|
if (capturedError != null) throw capturedError!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final SessionSingleFlight _refreshFlight = SessionSingleFlight();
|
||||||
|
|
||||||
|
/// Concurrent callers (tab switch, poll, push, resume) join the running
|
||||||
|
/// refresh instead of each fetching and re-emitting the full room list.
|
||||||
Future<void> refresh({bool renew = true, bool silent = false}) async {
|
Future<void> refresh({bool renew = true, bool silent = false}) async {
|
||||||
if (!requirementsMet) return;
|
if (!requirementsMet || !ownsSession) return;
|
||||||
|
await _refreshFlight.run(() => _refresh(renew: renew, silent: silent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skips the refresh when the list was fetched within [maxAge].
|
||||||
|
Future<void> refreshIfOlderThan(Duration maxAge) {
|
||||||
|
final lastFetch = state.lastFetch;
|
||||||
|
if (lastFetch != null &&
|
||||||
|
DateTime.now().millisecondsSinceEpoch - lastFetch <
|
||||||
|
maxAge.inMilliseconds) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
return refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh({required bool renew, required bool silent}) async {
|
||||||
if (!silent) add(RefetchStarted<ChatListState>());
|
if (!silent) add(RefetchStarted<ChatListState>());
|
||||||
Object? capturedError;
|
Object? capturedError;
|
||||||
try {
|
try {
|
||||||
@@ -87,6 +117,11 @@ class ChatListBloc
|
|||||||
renew: renew,
|
renew: renew,
|
||||||
onError: (e) => capturedError = e,
|
onError: (e) => capturedError = e,
|
||||||
);
|
);
|
||||||
|
if (silent) {
|
||||||
|
if (_isUnchanged(rooms)) return;
|
||||||
|
} else {
|
||||||
|
_lastRoomsJson = null;
|
||||||
|
}
|
||||||
add(DataGathered((s) => s.copyWith(rooms: rooms)));
|
add(DataGathered((s) => s.copyWith(rooms: rooms)));
|
||||||
_updateAppBadge(rooms);
|
_updateAppBadge(rooms);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -95,6 +130,23 @@ class ChatListBloc
|
|||||||
if (capturedError != null) addLoadingError(capturedError!);
|
if (capturedError != null) addLoadingError(capturedError!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encoded room data of the last applied poll result; response headers are
|
||||||
|
// left out because they differ on every request.
|
||||||
|
String? _lastRoomsJson;
|
||||||
|
|
||||||
|
/// A background poll that returns the same rooms would otherwise rebuild the
|
||||||
|
/// list and re-persist the whole state every 15 s.
|
||||||
|
bool _isUnchanged(GetRoomResponse rooms) {
|
||||||
|
final encoded = jsonEncode(rooms.data.toList());
|
||||||
|
final unchanged =
|
||||||
|
encoded == _lastRoomsJson &&
|
||||||
|
innerState?.rooms != null &&
|
||||||
|
state.error == null &&
|
||||||
|
!state.isLoading;
|
||||||
|
_lastRoomsJson = encoded;
|
||||||
|
return unchanged;
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in
|
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in
|
||||||
/// demo mode. Refreshes the list so the room shows up.
|
/// demo mode. Refreshes the list so the room shows up.
|
||||||
Future<String?> createDirectChat(String invite) async {
|
Future<String?> createDirectChat(String invite) async {
|
||||||
@@ -126,6 +178,18 @@ class ChatListBloc
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setNotificationLevel(
|
||||||
|
String token,
|
||||||
|
GetRoomResponseObjectParticipantNotificationLevel level,
|
||||||
|
) async {
|
||||||
|
await SetNotificationLevel(token, level).run();
|
||||||
|
_mutateRoom(token, (r) {
|
||||||
|
if (r.notificationLevel == level) return false;
|
||||||
|
r.notificationLevel = level;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Clears unread too — long-poll only feeds this in for an actively-open chat.
|
/// Clears unread too — long-poll only feeds this in for an actively-open chat.
|
||||||
void applyIncomingMessage(String token, GetChatResponseObject message) {
|
void applyIncomingMessage(String token, GetChatResponseObject message) {
|
||||||
_mutateRoom(token, (r) {
|
_mutateRoom(token, (r) {
|
||||||
@@ -158,6 +222,7 @@ class ChatListBloc
|
|||||||
}).toSet();
|
}).toSet();
|
||||||
if (!changed) return;
|
if (!changed) return;
|
||||||
final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
|
final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
|
||||||
|
_lastRoomsJson = null;
|
||||||
add(Emit((s) => s.copyWith(rooms: newRooms)));
|
add(Emit((s) => s.copyWith(rooms: newRooms)));
|
||||||
_updateAppBadge(newRooms);
|
_updateAppBadge(newRooms);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:enough_icalendar/enough_icalendar.dart';
|
import 'package:enough_icalendar/enough_icalendar.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../bloc/marianum_dates_state.dart';
|
import '../bloc/marianum_dates_state.dart';
|
||||||
|
|
||||||
@@ -19,6 +20,12 @@ class MarianumDatesGetEvents {
|
|||||||
final body = response.data;
|
final body = response.data;
|
||||||
if (body == null || body.isEmpty) return [];
|
if (body == null || body.isEmpty) return [];
|
||||||
|
|
||||||
|
// The public feed holds hundreds of events back to 1981; parsing it on
|
||||||
|
// the UI isolate froze the page for a noticeable moment on every open.
|
||||||
|
return compute(parseEvents, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<MarianumDate> parseEvents(String body) {
|
||||||
final root = VComponent.parse(body);
|
final root = VComponent.parse(body);
|
||||||
final calendar = root is VCalendar ? root : null;
|
final calendar = root is VCalendar ? root : null;
|
||||||
final source = calendar?.children ?? root.children;
|
final source = calendar?.children ?? root.children;
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ class NextcloudCapabilitiesCubit
|
|||||||
emit(DemoNextcloudCapabilities.state());
|
emit(DemoNextcloudCapabilities.state());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final epoch = SessionManager().sessionEpoch;
|
||||||
try {
|
try {
|
||||||
final caps = await GetNextcloudCapabilities().run();
|
final caps = await GetNextcloudCapabilities().run();
|
||||||
|
// A slow answer for the previous account must not decide the modules
|
||||||
|
// of the next one.
|
||||||
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
emit(
|
emit(
|
||||||
NextcloudCapabilitiesState(
|
NextcloudCapabilitiesState(
|
||||||
apiEnabled: caps.apiEnabled,
|
apiEnabled: caps.apiEnabled,
|
||||||
@@ -92,7 +96,7 @@ class NextcloudCapabilitiesCubit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() => emit(const NextcloudCapabilitiesState());
|
Future<void> reset() async => emit(const NextcloudCapabilitiesState());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
NextcloudCapabilitiesState fromJson(Map<String, dynamic> json) {
|
NextcloudCapabilitiesState fromJson(Map<String, dynamic> json) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||||
|
|
||||||
import '../../../../../storage/settings.dart';
|
import '../../../../../storage/settings.dart';
|
||||||
@@ -10,8 +11,15 @@ import '../../../../../view/pages/settings/data/default_settings.dart';
|
|||||||
class SettingsCubit extends HydratedCubit<Settings> {
|
class SettingsCubit extends HydratedCubit<Settings> {
|
||||||
static const _debounceTag = 'settings_persist';
|
static const _debounceTag = 'settings_persist';
|
||||||
bool _emitScheduled = false;
|
bool _emitScheduled = false;
|
||||||
|
Map<String, dynamic>? _lastEmittedJson;
|
||||||
|
Timer? _silentSave;
|
||||||
|
|
||||||
SettingsCubit({super.storage}) : super(DefaultSettings.get());
|
final Storage? _storage;
|
||||||
|
|
||||||
|
// ignore: use_super_parameters
|
||||||
|
SettingsCubit({Storage? storage})
|
||||||
|
: _storage = storage,
|
||||||
|
super(DefaultSettings.get(), storage: storage);
|
||||||
|
|
||||||
Settings val({bool write = false}) {
|
Settings val({bool write = false}) {
|
||||||
if (write) {
|
if (write) {
|
||||||
@@ -34,21 +42,53 @@ class SettingsCubit extends HydratedCubit<Settings> {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists in-place mutations without notifying listeners. For high-frequency
|
||||||
|
/// writes nobody renders live (chat drafts per keystroke): a regular write
|
||||||
|
/// would rebuild the app root and every settings watcher on each change.
|
||||||
|
void saveSilently() {
|
||||||
|
_silentSave?.cancel();
|
||||||
|
_silentSave = Timer(const Duration(milliseconds: 800), flushSilentSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a pending [saveSilently] right away (e.g. when the app pauses).
|
||||||
|
void flushSilentSave() {
|
||||||
|
if (_silentSave == null) return;
|
||||||
|
_silentSave!.cancel();
|
||||||
|
_silentSave = null;
|
||||||
|
(_storage ?? HydratedBloc.storage).write(storageToken, state.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
void _emitFreshInstance() {
|
void _emitFreshInstance() {
|
||||||
try {
|
try {
|
||||||
emit(Settings.fromJson(state.toJson()));
|
final json = state.toJson();
|
||||||
|
// The debounced emit usually follows the microtask emit with identical
|
||||||
|
// content; skip it instead of rebuilding every listener a second time.
|
||||||
|
if (const DeepCollectionEquality().equals(json, _lastEmittedJson)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastEmittedJson = json;
|
||||||
|
emit(Settings.fromJson(json));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('Failed to refresh settings state: $e');
|
log('Failed to refresh settings state: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> reset() async {
|
Future<void> reset() async {
|
||||||
|
_silentSave?.cancel();
|
||||||
|
_silentSave = null;
|
||||||
|
_lastEmittedJson = null;
|
||||||
emit(DefaultSettings.get());
|
emit(DefaultSettings.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modules missing from a stale persisted moduleOrder are handled at read
|
// Modules missing from a stale persisted moduleOrder are handled at read
|
||||||
// time by AppModule.effectiveModuleOrder (inserted at their default
|
// time by AppModule.effectiveModuleOrder (inserted at their default
|
||||||
// position) — no healing on hydration needed.
|
// position) — no healing on hydration needed.
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
flushSilentSave();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Settings fromJson(Map<String, dynamic> json) {
|
Settings fromJson(Map<String, dynamic> json) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../../../../../api/errors/ticker_content_unavailable_exception.dart';
|
import '../../../../../api/errors/ticker_content_unavailable_exception.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
|
||||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||||
@@ -17,7 +22,35 @@ class TickerPageBloc
|
|||||||
> {
|
> {
|
||||||
final String slug;
|
final String slug;
|
||||||
|
|
||||||
TickerPageBloc(this.slug);
|
TickerPageBloc(this.slug) {
|
||||||
|
unawaited(_rememberSlug());
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _recentSlugsKey = 'tickerPageRecentSlugs';
|
||||||
|
static const _keepPages = 50;
|
||||||
|
|
||||||
|
/// Every page ever opened used to stay in the hydrated box forever — and
|
||||||
|
/// the whole box is decoded before the first frame on each cold start.
|
||||||
|
/// The most recently opened pages (far more than a normal reader revisits)
|
||||||
|
/// keep their offline copy; only long-forgotten ones are dropped.
|
||||||
|
Future<void> _rememberSlug() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final recent = prefs.getStringList(_recentSlugsKey) ?? <String>[];
|
||||||
|
recent
|
||||||
|
..remove(slug)
|
||||||
|
..insert(0, slug);
|
||||||
|
for (final evicted in recent.skip(_keepPages)) {
|
||||||
|
await HydratedBloc.storage.delete('$storagePrefix$evicted');
|
||||||
|
}
|
||||||
|
await prefs.setStringList(
|
||||||
|
_recentSlugsKey,
|
||||||
|
recent.take(_keepPages).toList(),
|
||||||
|
);
|
||||||
|
} on Object {
|
||||||
|
// Best effort: a failed cleanup only leaves an extra offline copy.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get id => slug;
|
String get id => slug;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class TimetableBloc
|
|||||||
TimetableState fromNothing() {
|
TimetableState fromNothing() {
|
||||||
final reference = DateTime.now().addDays(2);
|
final reference = DateTime.now().addDays(2);
|
||||||
return TimetableState(
|
return TimetableState(
|
||||||
startDate: _startOfWeek(reference),
|
startDate: reference.mondayOfWeek,
|
||||||
endDate: _endOfWeek(reference),
|
endDate: _endOfWeek(reference),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ class TimetableBloc
|
|||||||
final stored = TimetableState.fromJson(json);
|
final stored = TimetableState.fromJson(json);
|
||||||
final reference = DateTime.now().addDays(2);
|
final reference = DateTime.now().addDays(2);
|
||||||
return stored.copyWith(
|
return stored.copyWith(
|
||||||
startDate: _startOfWeek(reference),
|
startDate: reference.mondayOfWeek,
|
||||||
endDate: _endOfWeek(reference),
|
endDate: _endOfWeek(reference),
|
||||||
accessibleStartDate: null,
|
accessibleStartDate: null,
|
||||||
accessibleEndDate: null,
|
accessibleEndDate: null,
|
||||||
@@ -120,13 +120,15 @@ class TimetableBloc
|
|||||||
final current = innerState ?? fromNothing();
|
final current = innerState ?? fromNothing();
|
||||||
if (current.startDate == startDate && current.endDate == endDate) return;
|
if (current.startDate == startDate && current.endDate == endDate) return;
|
||||||
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
|
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
|
||||||
_loadCurrentWeek(startDate, endDate);
|
runInSession(() {
|
||||||
_prefetchAdjacentWeeks(startDate, endDate);
|
_loadCurrentWeek(startDate, endDate);
|
||||||
|
_prefetchAdjacentWeeks(startDate, endDate);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void resetWeek() {
|
void resetWeek() {
|
||||||
final reference = DateTime.now().addDays(2);
|
final reference = DateTime.now().addDays(2);
|
||||||
changeWeek(_startOfWeek(reference), _endOfWeek(reference));
|
changeWeek(reference.mondayOfWeek, _endOfWeek(reference));
|
||||||
}
|
}
|
||||||
|
|
||||||
void refresh() => fetch();
|
void refresh() => fetch();
|
||||||
@@ -171,11 +173,7 @@ class TimetableBloc
|
|||||||
|
|
||||||
Future<void> _refreshSubjects() async {
|
Future<void> _refreshSubjects() async {
|
||||||
final subjects = await repo.data.getSubjects(renew: true);
|
final subjects = await repo.data.getSubjects(renew: true);
|
||||||
add(
|
add(DataGathered((s) => s.withReferenceData(subjects: subjects)));
|
||||||
DataGathered(
|
|
||||||
(s) => s.copyWith(subjects: subjects, dataVersion: s.dataVersion + 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadCurrentWeek(
|
Future<void> _loadCurrentWeek(
|
||||||
@@ -214,12 +212,11 @@ class TimetableBloc
|
|||||||
|
|
||||||
add(
|
add(
|
||||||
Emit(
|
Emit(
|
||||||
(s) => s.copyWith(
|
(s) => s.withReferenceData(
|
||||||
rooms: rooms,
|
rooms: rooms,
|
||||||
subjects: subjects,
|
subjects: subjects,
|
||||||
schoolHolidays: schoolHolidays,
|
schoolHolidays: schoolHolidays,
|
||||||
schoolyear: schoolyear,
|
schoolyear: schoolyear,
|
||||||
dataVersion: s.dataVersion + 1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -229,11 +226,7 @@ class TimetableBloc
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final timegrid = await repo.data.getTimegrid(renew: renew);
|
final timegrid = await repo.data.getTimegrid(renew: renew);
|
||||||
add(
|
add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
|
||||||
Emit(
|
|
||||||
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Timegrid load failure falls back to a hardcoded schedule in the UI layer.
|
// Timegrid load failure falls back to a hardcoded schedule in the UI layer.
|
||||||
}
|
}
|
||||||
@@ -249,12 +242,7 @@ class TimetableBloc
|
|||||||
renew: renew,
|
renew: renew,
|
||||||
onError: onError,
|
onError: onError,
|
||||||
);
|
);
|
||||||
add(
|
add(Emit((s) => s.withReferenceData(customEvents: events)));
|
||||||
Emit(
|
|
||||||
(s) =>
|
|
||||||
s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
onError?.call(e);
|
onError?.call(e);
|
||||||
}
|
}
|
||||||
@@ -262,11 +250,7 @@ class TimetableBloc
|
|||||||
|
|
||||||
Future<void> _refreshCustomEvents() async {
|
Future<void> _refreshCustomEvents() async {
|
||||||
final events = await repo.data.getCustomEvents(renew: true);
|
final events = await repo.data.getCustomEvents(renew: true);
|
||||||
add(
|
add(DataGathered((s) => s.withReferenceData(customEvents: events)));
|
||||||
DataGathered(
|
|
||||||
(s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
|
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
|
||||||
@@ -282,19 +266,7 @@ class TimetableBloc
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
|
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
|
||||||
final key = weekStart.weekKey();
|
add(Emit((s) => s.withFetchedWeek(weekStart, week)));
|
||||||
add(
|
|
||||||
Emit((s) {
|
|
||||||
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
|
|
||||||
updated[key] = week;
|
|
||||||
return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static DateTime _startOfWeek(DateTime reference) {
|
|
||||||
final monday = reference.subtractDays(reference.weekday - 1);
|
|
||||||
return DateTime(monday.year, monday.month, monday.day);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static DateTime _endOfWeek(DateTime reference) {
|
static DateTime _endOfWeek(DateTime reference) {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timeta
|
|||||||
import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
|
import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
|
||||||
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||||
import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
||||||
|
import '../../../../../utils/json_equality.dart';
|
||||||
|
import 'week_cache.dart';
|
||||||
|
|
||||||
part 'timetable_state.freezed.dart';
|
part 'timetable_state.freezed.dart';
|
||||||
part 'timetable_state.g.dart';
|
part 'timetable_state.g.dart';
|
||||||
@@ -40,6 +42,62 @@ abstract class TimetableState with _$TimetableState {
|
|||||||
Iterable<McTimetableEntry> getAllKnownLessons() =>
|
Iterable<McTimetableEntry> getAllKnownLessons() =>
|
||||||
weekCache.values.expand((response) => response.entries);
|
weekCache.values.expand((response) => response.entries);
|
||||||
|
|
||||||
|
/// This state with [week] cached, or `this` when its content is unchanged
|
||||||
|
/// (so the emit is dropped as equal).
|
||||||
|
TimetableState withFetchedWeek(
|
||||||
|
DateTime weekStart,
|
||||||
|
TimetableGetWeekResponse week,
|
||||||
|
) {
|
||||||
|
final updated = mergeWeekIntoCache(
|
||||||
|
weekCache,
|
||||||
|
weekStart,
|
||||||
|
week,
|
||||||
|
viewedWeekStart: startDate,
|
||||||
|
now: DateTime.now(),
|
||||||
|
);
|
||||||
|
if (updated == null) return this;
|
||||||
|
return copyWith(weekCache: updated, dataVersion: dataVersion + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies freshly loaded reference data. Values whose content equals the
|
||||||
|
/// current one keep the old instance, and when nothing changed at all this
|
||||||
|
/// returns `this` — every `dataVersion` bump and new identity makes the
|
||||||
|
/// calendar rebuild all appointments and break regions.
|
||||||
|
TimetableState withReferenceData({
|
||||||
|
TimetableGetRoomsResponse? rooms,
|
||||||
|
TimetableGetSubjectsResponse? subjects,
|
||||||
|
TimetableGetHolidaysResponse? schoolHolidays,
|
||||||
|
TimetableGetSchoolyearResponse? schoolyear,
|
||||||
|
TimetableGetTimegridResponse? timegrid,
|
||||||
|
GetCustomTimetableEventResponse? customEvents,
|
||||||
|
}) {
|
||||||
|
T? changed<T>(T? next, T? current) =>
|
||||||
|
next == null || sameJson(next, current) ? null : next;
|
||||||
|
final newRooms = changed(rooms, this.rooms);
|
||||||
|
final newSubjects = changed(subjects, this.subjects);
|
||||||
|
final newHolidays = changed(schoolHolidays, this.schoolHolidays);
|
||||||
|
final newSchoolyear = changed(schoolyear, this.schoolyear);
|
||||||
|
final newTimegrid = changed(timegrid, this.timegrid);
|
||||||
|
final newCustomEvents = changed(customEvents, this.customEvents);
|
||||||
|
if (newRooms == null &&
|
||||||
|
newSubjects == null &&
|
||||||
|
newHolidays == null &&
|
||||||
|
newSchoolyear == null &&
|
||||||
|
newTimegrid == null &&
|
||||||
|
newCustomEvents == null) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
return copyWith(
|
||||||
|
rooms: newRooms ?? this.rooms,
|
||||||
|
subjects: newSubjects ?? this.subjects,
|
||||||
|
schoolHolidays: newHolidays ?? this.schoolHolidays,
|
||||||
|
schoolyear: newSchoolyear ?? this.schoolyear,
|
||||||
|
timegrid: newTimegrid ?? this.timegrid,
|
||||||
|
customEvents: newCustomEvents ?? this.customEvents,
|
||||||
|
dataVersion: dataVersion + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the calendar has everything it needs to render. Custom events
|
/// Whether the calendar has everything it needs to render. Custom events
|
||||||
/// only exist for subjects that support them; requiring them elsewhere would
|
/// only exist for subjects that support them; requiring them elsewhere would
|
||||||
/// keep foreign plans loading forever.
|
/// keep foreign plans loading forever.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user