diff --git a/CLAUDE.md b/CLAUDE.md index 62e8e27..96e146e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). -**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_`, `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_`, `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. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index dba5873..bd05e24 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ .. --> @@ -110,6 +111,7 @@ diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/MainActivity.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/MainActivity.kt index cf439fc..3c68c8a 100644 --- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/MainActivity.kt +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/MainActivity.kt @@ -1,15 +1,38 @@ package eu.mhsl.marianum.mobile.client import android.content.Intent +import android.os.Bundle import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { private val widgetChannel = "eu.mhsl.marianum.widget" + private val shareChannel = "eu.mhsl.marianum.share" /// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation` /// so the same intent isn't replayed on every resume. private var pendingTimetableTap: Boolean = false + private val shareActions = setOf(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE) + + /// receive_sharing_intent only emits warm shares to a live Dart listener and + /// does not buffer them. After a relaunch into an existing task the share + /// arrives via onNewIntent before Dart subscribed, so it is held here until + /// Dart reports `listenerReady`. + private var shareListenerReady = false + private var bufferedShare: Intent? = null + + override fun onCreate(savedInstanceState: Bundle?) { + // Android replays the original launch intent when the activity is + // recreated from recents or after process death. A share or widget tap + // that was already handled would otherwise run again — for shares with + // temp files that are long gone. + val replayed = savedInstanceState != null || + (intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0 + if (replayed && (intent.action in shareActions || isWidgetTap(intent))) { + intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + } + super.onCreate(savedInstanceState) + } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -26,17 +49,35 @@ class MainActivity : FlutterActivity() { else -> result.notImplemented() } } + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + shareChannel + ).setMethodCallHandler { call, result -> + when (call.method) { + "listenerReady" -> { + shareListenerReady = true + bufferedShare?.let { flutterEngine.activityControlSurface.onNewIntent(it) } + bufferedShare = null + result.success(null) + } + else -> result.notImplemented() + } + } consumeIntentData(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + if (!shareListenerReady && intent.action in shareActions) { + bufferedShare = intent + } consumeIntentData(intent) } + private fun isWidgetTap(intent: Intent?): Boolean = + intent?.getBooleanExtra("widget_open_timetable", false) == true + private fun consumeIntentData(intent: Intent?) { - if (intent?.getBooleanExtra("widget_open_timetable", false) == true) { - pendingTimetableTap = true - } + if (isWidgetTap(intent)) pendingTimetableTap = true } } diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt index 5c0ccd2..57b8324 100644 --- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt @@ -11,7 +11,7 @@ import java.util.TimeZone // Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names // must stay in sync. enum class WidgetLessonStatus { - REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT; + REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT, DUTY; companion object { fun fromWire(raw: String?): WidgetLessonStatus = when (raw) { @@ -22,6 +22,7 @@ enum class WidgetLessonStatus { "irregular" -> IRREGULAR "teacherChanged" -> TEACHER_CHANGED "event" -> EVENT + "duty" -> DUTY else -> REGULAR } } @@ -39,6 +40,9 @@ data class WidgetLesson( val originalTeacher: String?, val status: WidgetLessonStatus, val customColor: String?, + // Tile colour resolved in Dart (subject colours, palettes); null only for + // payloads written before the field existed. + val color: Int?, val siblingCount: Int, ) @@ -50,6 +54,12 @@ data class WidgetPeriod( val virtualEndMinutes: Int, ) +data class WidgetDayInfo( + val date: Date, + val isHoliday: Boolean, + val holidayName: String?, +) + data class WidgetTimetableData( val fetchedAt: Date, val anchorDate: Date, @@ -57,6 +67,8 @@ data class WidgetTimetableData( val periods: List, val isHoliday: Boolean, val holidayName: String?, + // Week payload only; empty for the day payload. + val days: List, ) object WidgetDataParser { @@ -81,6 +93,11 @@ object WidgetDataParser { } } + private fun parseColor(raw: String?): Int? { + if (raw == null || !Regex("#[0-9A-Fa-f]{6}").matches(raw)) return null + return 0xFF000000.toInt() or raw.substring(1).toInt(16) + } + fun parse(json: String?): WidgetTimetableData? { if (json.isNullOrEmpty()) return null return try { @@ -102,6 +119,7 @@ object WidgetDataParser { originalTeacher = obj.stringOrNull("originalTeacher"), status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")), customColor = obj.stringOrNull("customColor"), + color = parseColor(obj.stringOrNull("color")), siblingCount = obj.optInt("siblingCount", 0), ) } @@ -120,6 +138,18 @@ object WidgetDataParser { ) } } + val daysArray = root.optJSONArray("days") + val days = mutableListOf() + if (daysArray != null) { + for (i in 0 until daysArray.length()) { + val obj = daysArray.optJSONObject(i) ?: continue + days += WidgetDayInfo( + date = parseDate(obj.stringOrNull("date")) ?: continue, + isHoliday = obj.optBoolean("isHoliday", false), + holidayName = obj.stringOrNull("holidayName"), + ) + } + } WidgetTimetableData( fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(), anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(), @@ -127,6 +157,7 @@ object WidgetDataParser { periods = periods, isHoliday = root.optBoolean("isHoliday", false), holidayName = root.stringOrNull("holidayName"), + days = days, ) } catch (_: JSONException) { null diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt index 08c5875..23a9c3a 100644 --- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt @@ -5,8 +5,14 @@ import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.content.res.ColorStateList import android.content.res.Configuration +import android.graphics.Typeface import android.os.Bundle +import android.text.SpannableString +import android.text.Spanned +import android.text.TextPaint +import android.text.style.StyleSpan import android.util.TypedValue import android.view.View import android.widget.RemoteViews @@ -16,40 +22,89 @@ import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import kotlin.math.floor import kotlin.math.max +import kotlin.math.min /** - * Renders the day and week widgets as a time-grid: lesson blocks absolutely - * positioned on a vertical axis, mirroring the in-app Syncfusion calendar. - * Per-hour dp is computed from the widget bundle so the grid scales with - * resize, clamped to [MIN_HOUR_HEIGHT_DP, MAX_HOUR_HEIGHT_DP]. + * Renders the day and week widgets as a period grid that mirrors the in-app + * timetable (AppointmentTile, _PeriodLabel, _DayHeaderCell): lesson tiles + * absolutely positioned on a vertical axis, scaled down from the app's + * proportions to whatever height the launcher grants. */ object WidgetRenderer { private const val FALLBACK_VIRTUAL_MINUTES = 11 * 60 + private const val FALLBACK_SLOT_MINUTES = 45 + private const val FALLBACK_HOUR_HEIGHT_DP = 32f - private const val DAY_CHROME_DP = 40 - private const val WEEK_CHROME_DP = 64 + // Chrome metrics — must match widget_day.xml / widget_week.xml, the grid + // height is derived from them. + private const val CHROME_PADDING_H_DP = 16f + private const val CHROME_PADDING_V_DP = 18f + private const val TITLE_ROW_DP = 20f + private const val SUBTITLE_BELOW_DP = 12f + private const val GRID_MARGIN_TOP_DP = 4f + private const val WEEK_HEADER_DP = 32f + 2f + private const val RULER_WIDTH_DP = 30f + 1f + private const val WEEK_DIVIDERS_DP = 4f - private const val MIN_HOUR_HEIGHT_DP = 18 - private const val MAX_HOUR_HEIGHT_DP = 72 + /// Below this width title and freshness label no longer fit side by side. + private const val DAY_NARROW_WIDTH_DP = 200f - private const val MIN_BLOCK_HEIGHT_DP = 16 + /// 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. + private const val MIN_VISIBLE_PERIODS = 6 + + private const val MIN_HOUR_HEIGHT_DP = 18f + private const val MAX_HOUR_HEIGHT_DP = 72f + + private const val MIN_BLOCK_HEIGHT_DP = 16f private const val LESSON_GAP_DP = 1.5f - // Below SHOW_ROOM_MIN: subject only. Below SHOW_TEACHER_SEPARATE_MIN: - // subject + room. Above: subject + room + teacher stacked. - private const val BLOCK_SHOW_ROOM_MIN_DP = 18 - private const val BLOCK_SHOW_TEACHER_SEPARATE_MIN_DP = 30 + // Tile metrics — must match widget_lesson_block.xml. + private const val TILE_MARGIN_H_DP = 1f + private const val TILE_PADDING_H_DP = 4f + private const val TILE_PADDING_V_DP = 2f - /// Below this column width autoSize can't fit subject + room — drop - /// room/teacher entirely on the week-widget. - private const val WEEK_COLUMN_TIGHT_DP = 45 + // In-app tile: 15sp title over 10sp body lines. The widget keeps that + // ratio and scales both so title + room + teacher fill one period. + private const val TITLE_TO_BODY_RATIO = 1.5f + private const val MAX_BODY_FONT_DP = 10f + private const val MIN_BODY_FONT_DP = 6.5f + private const val LINE_BOX_PER_FONT = 1.2f + + /// If stacking room and teacher would push the body font below this, + /// both share one line instead so the text stays readable. + private const val STACKED_BODY_MIN_FONT_DP = 7.5f + + /// 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. + private const val TITLE_WRAP_BELOW_FONT_DP = 8f + + /// Slack for the launcher-reported width when sizing text ahead of layout. + private const val WIDTH_ESTIMATE_SAFETY = 0.95f + + // Footprint of the "+N" badge in the tile's bottom end corner. + private const val BADGE_WIDTH_DP = 16f + private const val BADGE_HEIGHT_DP = 11f + + private const val RULER_TIMES_MIN_CELL_DP = 30f + + /// Space the holiday column keeps free above and below the rotated name + /// for the icon and the "Schulfrei" label. + private const val HOLIDAY_NAME_INSET_DP = 44f + private const val BREAK_ICON_MIN_HEIGHT_DP = 12f private val timeFormat = SimpleDateFormat("HH:mm", Locale.GERMAN) private val dateShort = SimpleDateFormat("dd.MM.", Locale.GERMAN) private val weekdayShort = SimpleDateFormat("EE", Locale.GERMAN) private val dateTimeShort = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN) + private val titleTypeface: Typeface by lazy { + Typeface.create("sans-serif-medium", Typeface.NORMAL) + } + /// Hex values mirror LightAppTheme / DarkAppTheme tokens so the widget /// matches the app's branding rather than the generic system look. private data class WidgetPalette( @@ -57,7 +112,11 @@ object WidgetRenderer { val textPrimary: Int, val textSecondary: Int, val divider: Int, + val accent: Int, + val onAccent: Int, val breakBlock: Int, + val todayTint: Int, + val holidayTint: Int, val watermarkAlpha: Float, ) @@ -66,7 +125,11 @@ object WidgetRenderer { textPrimary = 0xFF1A1A1A.toInt(), textSecondary = 0xFF555555.toInt(), divider = 0x22000000, - breakBlock = 0x0C000000, + accent = 0xFF904A47.toInt(), + onAccent = 0xFFFFFFFF.toInt(), + breakBlock = 0x32904A47, + todayTint = 0x0E904A47, + holidayTint = 0x32000000, watermarkAlpha = 0.014f, ) @@ -75,10 +138,54 @@ object WidgetRenderer { textPrimary = 0xFFF1F1F1.toInt(), textSecondary = 0xFFB0B0B0.toInt(), divider = 0x33FFFFFF, - breakBlock = 0x14FFFFFF, + accent = 0xFFFFB3AF.toInt(), + onAccent = 0xFF571D1D.toInt(), + breakBlock = 0x32FFB3AF, + todayTint = 0x0EFFB3AF, + holidayTint = 0x32FFFFFF, watermarkAlpha = 0.025f, ) + private data class SizeDp(val width: Float, val height: Float) + + /// Font and line-box sizes shared by every tile of one widget, derived + /// from the height of a single period. + private class TileMetrics(slotHeightDp: Float) { + val maxBodyLines: Int + val titleBoxDp: Float + val bodyBoxDp: Float + val bodyFontDp: Float + + init { + val inner = slotHeightDp - LESSON_GAP_DP - 2 * TILE_PADDING_V_DP + fun bodyFont(lines: Int) = + inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines)) + maxBodyLines = if (bodyFont(2) < STACKED_BODY_MIN_FONT_DP) 1 else 2 + bodyFontDp = bodyFont(maxBodyLines) + .coerceIn(MIN_BODY_FONT_DP, MAX_BODY_FONT_DP) + titleBoxDp = bodyFontDp * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT + bodyBoxDp = bodyFontDp * LINE_BOX_PER_FONT + } + } + + private class GridContext( + val context: Context, + val packageName: String, + val palette: WidgetPalette, + val periods: List, + val hourHeightDp: Float, + /// Null when the launcher reported no size — text wrapping decisions + /// that depend on the tile width are skipped then. + val tileInnerWidthDp: Float?, + ) { + val metrics = TileMetrics( + (periods.firstOrNull()?.let { it.virtualEndMinutes - it.virtualStartMinutes } + ?: FALLBACK_SLOT_MINUTES) * hourHeightDp / 60f, + ) + + fun dp(virtualMinutes: Float): Float = virtualMinutes * hourHeightDp / 60f + } + private fun resolvePalette(context: Context, themeMode: String?): WidgetPalette { val isDark = when (themeMode) { "light" -> false @@ -114,63 +221,9 @@ object WidgetRenderer { context.getString(R.string.widget_loading), palette, ) - - val totalVirtualMin = data.periods.lastOrNull()?.virtualEndMinutes - ?: FALLBACK_VIRTUAL_MINUTES - val hourHeightDp = resolveHourHeight(options, DAY_CHROME_DP, totalVirtualMin) - - val views = RemoteViews(packageName, R.layout.widget_day) - applyChrome(views, palette, options) - views.setTextColor(R.id.widget_day_title, palette.textPrimary) - views.setTextColor(R.id.widget_day_subtitle, palette.textSecondary) - views.setTextColor(R.id.widget_day_empty, palette.textSecondary) - views.setTextViewText( - R.id.widget_day_title, - "${dayLabel(context, data.anchorDate)} · ${dateShort.format(data.anchorDate)}", - ) - views.setTextViewText( - R.id.widget_day_subtitle, - context.getString(R.string.widget_status_label, freshnessLabel(context, data.fetchedAt)), - ) - - views.removeAllViews(R.id.widget_day_time_labels) - views.removeAllViews(R.id.widget_day_grid) - - if (data.isHoliday) { - views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) - views.setTextViewText( - R.id.widget_day_empty, - data.holidayName ?: context.getString(R.string.widget_holiday), - ) - } else if (data.lessons.isEmpty()) { - views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) - views.setTextViewText( - R.id.widget_day_empty, - context.getString(R.string.widget_no_lessons), - ) - } else { - views.setViewVisibility(R.id.widget_day_empty, View.GONE) - populateGridLines(packageName, views, R.id.widget_day_time_labels, hourHeightDp, palette, data.periods) - populateTimeLabels(packageName, views, R.id.widget_day_time_labels, hourHeightDp, palette, data.periods) - populateGridLines(packageName, views, R.id.widget_day_grid, hourHeightDp, palette, data.periods) - populateBreakBlocks(packageName, views, R.id.widget_day_grid, hourHeightDp, palette, data.periods) - for (lesson in data.lessons) { - addLessonBlock( - context = context, - packageName = packageName, - parent = views, - containerId = R.id.widget_day_grid, - lesson = lesson, - hourHeightDp = hourHeightDp, - periods = data.periods, - subjectOnly = false, - horizontalPaddingDp = 7, - ) - } + return forBothOrientations(options) { size -> + renderDay(context, packageName, palette, data, size) } - - views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context)) - return views } fun buildWeek( @@ -195,13 +248,152 @@ object WidgetRenderer { context.getString(R.string.widget_loading), palette, ) + return forBothOrientations(options) { size -> + renderWeek(context, packageName, palette, data, size) + } + } - val totalVirtualMin = data.periods.lastOrNull()?.virtualEndMinutes - ?: FALLBACK_VIRTUAL_MINUTES - val hourHeightDp = resolveHourHeight(options, WEEK_CHROME_DP, totalVirtualMin) + /// The launcher swaps between the two layouts on rotation without asking + /// the provider again, so both have to be laid out for their own size. + private fun forBothOrientations( + options: Bundle?, + render: (SizeDp?) -> RemoteViews, + ): RemoteViews { + val sizes = widgetSizes(options) ?: return render(null) + val (portrait, landscape) = sizes + if (portrait == landscape) return render(portrait) + return RemoteViews(render(landscape), render(portrait)) + } + + /// Launchers report the widget as a min/max box: portrait is the narrow + /// and tall combination, landscape the wide and short one. + private fun widgetSizes(options: Bundle?): Pair? { + if (options == null) return null + val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0) + val maxWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0) + val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0) + val maxHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0) + val portrait = SizeDp( + (if (minWidth > 0) minWidth else maxWidth).toFloat(), + (if (maxHeight > 0) maxHeight else minHeight).toFloat(), + ) + val landscape = SizeDp( + (if (maxWidth > 0) maxWidth else minWidth).toFloat(), + (if (minHeight > 0) minHeight else maxHeight).toFloat(), + ) + if (portrait.width <= 0 || portrait.height <= 0) return null + return Pair(portrait, landscape) + } + + private fun renderDay( + context: Context, + packageName: String, + palette: WidgetPalette, + data: WidgetTimetableData, + size: SizeDp?, + ): RemoteViews { + val narrow = size != null && size.width < DAY_NARROW_WIDTH_DP + val chromeDp = CHROME_PADDING_V_DP + TITLE_ROW_DP + GRID_MARGIN_TOP_DP + + (if (narrow) SUBTITLE_BELOW_DP else 0f) + val periods = trimTrailingPeriods(data.periods, data.lessons) + val grid = GridContext( + context = context, + packageName = packageName, + palette = palette, + periods = periods, + hourHeightDp = resolveHourHeight(size, chromeDp, periods), + tileInnerWidthDp = size?.let { + it.width - CHROME_PADDING_H_DP - RULER_WIDTH_DP - tileInsetsDp() + }, + ) + + val views = RemoteViews(packageName, R.layout.widget_day) + applyChrome(views, palette, size) + views.setTextColor(R.id.widget_day_title, palette.textPrimary) + views.setTextColor(R.id.widget_day_subtitle, palette.textSecondary) + views.setTextColor(R.id.widget_day_subtitle_below, palette.textSecondary) + views.setTextColor(R.id.widget_day_empty, palette.textSecondary) + views.setTextViewText( + R.id.widget_day_title, + "${dayLabel(context, data.anchorDate)} · ${dateShort.format(data.anchorDate)}", + ) + val freshness = context.getString( + R.string.widget_status_label, + freshnessLabel(context, data.fetchedAt), + ) + views.setTextViewText(R.id.widget_day_subtitle, freshness) + views.setTextViewText(R.id.widget_day_subtitle_below, freshness) + views.setViewVisibility( + R.id.widget_day_subtitle, + if (narrow) View.GONE else View.VISIBLE, + ) + views.setViewVisibility( + R.id.widget_day_subtitle_below, + if (narrow) View.VISIBLE else View.GONE, + ) + + views.removeAllViews(R.id.widget_day_time_labels) + views.removeAllViews(R.id.widget_day_grid) + + if (data.isHoliday) { + views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) + views.setTextViewText( + R.id.widget_day_empty, + data.holidayName ?: context.getString(R.string.widget_holiday), + ) + } else if (data.lessons.isEmpty()) { + views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) + views.setTextViewText( + R.id.widget_day_empty, + context.getString(R.string.widget_no_lessons), + ) + } else { + views.setViewVisibility(R.id.widget_day_empty, View.GONE) + populateRuler(grid, views, R.id.widget_day_time_labels) + populateGridLines(grid, views, R.id.widget_day_grid) + populateBreakBlocks(grid, views, R.id.widget_day_grid) + for (lesson in data.lessons) { + addLessonBlock(grid, views, R.id.widget_day_grid, lesson) + } + } + + views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context)) + return views + } + + private fun renderWeek( + context: Context, + packageName: String, + palette: WidgetPalette, + data: WidgetTimetableData, + size: SizeDp?, + ): RemoteViews { + val chromeDp = CHROME_PADDING_V_DP + TITLE_ROW_DP + GRID_MARGIN_TOP_DP + + WEEK_HEADER_DP + val weekDays = (0 until 5).map { offset -> + Calendar.getInstance().apply { + time = data.anchorDate + add(Calendar.DAY_OF_YEAR, offset) + }.time + } + val weekLessons = data.lessons.filter { lesson -> + weekDays.any { WidgetDateUtils.isSameDay(lesson.start, it) } + } + val periods = trimTrailingPeriods(data.periods, weekLessons) + val grid = GridContext( + context = context, + packageName = packageName, + palette = palette, + periods = periods, + hourHeightDp = resolveHourHeight(size, chromeDp, periods), + tileInnerWidthDp = size?.let { + (it.width - CHROME_PADDING_H_DP - RULER_WIDTH_DP - WEEK_DIVIDERS_DP) / 5f - + tileInsetsDp() + }, + ) val views = RemoteViews(packageName, R.layout.widget_week) - applyChrome(views, palette, options) + applyChrome(views, palette, size) views.setTextColor(R.id.widget_week_title, palette.textPrimary) views.setTextColor(R.id.widget_week_subtitle, palette.textSecondary) val cal = Calendar.getInstance().apply { time = data.anchorDate } @@ -235,45 +427,36 @@ object WidgetRenderer { R.id.widget_week_col_fri, ) views.removeAllViews(R.id.widget_week_time_labels) - populateGridLines(packageName, views, R.id.widget_week_time_labels, hourHeightDp, palette, data.periods) - populateTimeLabels(packageName, views, R.id.widget_week_time_labels, hourHeightDp, palette, data.periods) - - val (weekWidthDp, _) = widgetSizeDp(options) - // Time-label column is 28dp wide; the rest is split across 5 days - // plus thin dividers (negligible). Drop room/teacher only on the - // very narrowest week widgets — autoSize handles the in-between - // sizes. - val dayColumnWidthDp = (weekWidthDp - 28f - 20f) / 5f - val subjectOnly = dayColumnWidthDp < WEEK_COLUMN_TIGHT_DP + populateRuler(grid, views, R.id.widget_week_time_labels) + val today = Date() for ((index, columnId) in columnIds.withIndex()) { views.removeAllViews(headerIds[index]) views.removeAllViews(columnId) - val day = Calendar.getInstance().apply { - time = data.anchorDate - add(Calendar.DAY_OF_YEAR, index) - }.time - val header = RemoteViews(packageName, R.layout.widget_week_day_header) - header.setTextColor(R.id.widget_week_day_header_weekday, palette.textPrimary) - header.setTextColor(R.id.widget_week_day_header_date, palette.textSecondary) - header.setTextViewText(R.id.widget_week_day_header_weekday, weekdayShort.format(day)) - header.setTextViewText(R.id.widget_week_day_header_date, dateShort.format(day)) - views.addView(headerIds[index], header) + val day = weekDays[index] + val isToday = WidgetDateUtils.isSameDay(day, today) + val holiday = data.days.firstOrNull { + it.isHoliday && WidgetDateUtils.isSameDay(it.date, day) + } + views.addView(headerIds[index], buildDayHeader(grid, day, isToday)) + views.setInt( + columnId, + "setBackgroundColor", + when { + holiday != null -> palette.holidayTint + isToday -> palette.todayTint + else -> 0 + }, + ) - populateGridLines(packageName, views, columnId, hourHeightDp, palette, data.periods) - populateBreakBlocks(packageName, views, columnId, hourHeightDp, palette, data.periods) - for (lesson in data.lessons.filter { WidgetDateUtils.isSameDay(it.start, day) }) { - addLessonBlock( - context = context, - packageName = packageName, - parent = views, - containerId = columnId, - lesson = lesson, - hourHeightDp = hourHeightDp, - periods = data.periods, - subjectOnly = subjectOnly, - horizontalPaddingDp = 3, - ) + if (holiday != null) { + views.addView(columnId, buildHolidayColumn(grid, holiday)) + } else { + populateGridLines(grid, views, columnId) + populateBreakBlocks(grid, views, columnId) + } + for (lesson in weekLessons.filter { WidgetDateUtils.isSameDay(it.start, day) }) { + addLessonBlock(grid, views, columnId, lesson) } } @@ -281,27 +464,90 @@ object WidgetRenderer { return views } - /// Pulls the launcher-reported widget size out of the AppWidget options - /// bundle. The grid now spans `totalVirtualMin` minutes (lessons + - /// preserved big breaks), so we divide by that instead of a fixed hour - /// count to keep tiles readable across different timetables. - private fun resolveHourHeight( - options: Bundle?, - chromeDp: Int, - totalVirtualMin: Int, - ): Float { - val virtualHours = (totalVirtualMin / 60.0f).coerceAtLeast(1f) - val rawHeightDp = options?.let { - max( - it.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0), - it.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0), + private fun trimTrailingPeriods( + periods: List, + lessons: List, + ): List { + if (lessons.isEmpty()) return periods + val cal = Calendar.getInstance() + val latestEnd = lessons.maxOf { lesson -> + cal.time = lesson.end + cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE) + } + val lastUsed = periods.indexOfLast { it.startMinutes < latestEnd } + return periods.take(max(lastUsed + 1, MIN_VISIBLE_PERIODS)) + } + + private fun tileInsetsDp(): Float = 2 * (TILE_MARGIN_H_DP + TILE_PADDING_H_DP) + + private fun buildDayHeader(grid: GridContext, day: Date, isToday: Boolean): RemoteViews { + val palette = grid.palette + val header = RemoteViews(grid.packageName, R.layout.widget_week_day_header) + header.setTextViewText( + R.id.widget_week_day_header_weekday, + weekdayShort.format(day).uppercase(Locale.GERMAN), + ) + header.setTextColor( + R.id.widget_week_day_header_weekday, + if (isToday) palette.accent else palette.textSecondary, + ) + val dayOfMonth = Calendar.getInstance().apply { time = day } + .get(Calendar.DAY_OF_MONTH).toString() + if (isToday) { + val bold = SpannableString(dayOfMonth).apply { + setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + header.setTextViewText(R.id.widget_week_day_header_date, bold) + header.setTextColor(R.id.widget_week_day_header_date, palette.onAccent) + header.setInt( + R.id.widget_week_day_header_date, + "setBackgroundResource", + R.drawable.widget_today_circle, ) - } ?: 0 - if (rawHeightDp <= 0) return 32f - val gridHeightDp = (rawHeightDp - chromeDp) - .coerceAtLeast((MIN_HOUR_HEIGHT_DP * virtualHours).toInt()) - return (gridHeightDp.toFloat() / virtualHours) - .coerceIn(MIN_HOUR_HEIGHT_DP.toFloat(), MAX_HOUR_HEIGHT_DP.toFloat()) + header.setColorStateList( + R.id.widget_week_day_header_date, + "setBackgroundTintList", + ColorStateList.valueOf(palette.accent), + ) + } else { + header.setTextViewText(R.id.widget_week_day_header_date, dayOfMonth) + header.setTextColor(R.id.widget_week_day_header_date, palette.textPrimary) + header.setInt(R.id.widget_week_day_header_date, "setBackgroundResource", 0) + } + return header + } + + private fun buildHolidayColumn(grid: GridContext, holiday: WidgetDayInfo): RemoteViews { + val palette = grid.palette + val column = RemoteViews(grid.packageName, R.layout.widget_holiday_column) + column.setInt(R.id.widget_holiday_icon, "setColorFilter", palette.textPrimary) + column.setTextColor(R.id.widget_holiday_label, palette.textPrimary) + column.setTextColor(R.id.widget_holiday_name, palette.textPrimary) + column.setTextViewText(R.id.widget_holiday_name, holiday.holidayName ?: "") + val totalVirtualMin = grid.periods.lastOrNull()?.virtualEndMinutes + ?: FALLBACK_VIRTUAL_MINUTES + column.setViewLayoutWidth( + R.id.widget_holiday_name, + (grid.dp(totalVirtualMin.toFloat()) - 2 * HOLIDAY_NAME_INSET_DP).coerceAtLeast(40f), + TypedValue.COMPLEX_UNIT_DIP, + ) + return column + } + + /// The grid spans the periods' virtual minutes (lessons plus preserved + /// big breaks), so the per-hour height follows the actual timetable + /// instead of a fixed hour count. + private fun resolveHourHeight( + size: SizeDp?, + chromeDp: Float, + periods: List, + ): Float { + if (size == null) return FALLBACK_HOUR_HEIGHT_DP + val totalVirtualMin = periods.lastOrNull()?.virtualEndMinutes + ?: FALLBACK_VIRTUAL_MINUTES + val virtualHours = (totalVirtualMin / 60.0f).coerceAtLeast(1f) + return ((size.height - chromeDp) / virtualHours) + .coerceIn(MIN_HOUR_HEIGHT_DP, MAX_HOUR_HEIGHT_DP) } /// Real wall-clock minute → position on the virtual axis. Inside a @@ -343,74 +589,64 @@ object WidgetRenderer { return 0f } - /// Below this per-hour height the two-line label collapses to a single - /// period number — time + number overlap otherwise. - private const val TIME_LABEL_COMPACT_THRESHOLD_DP = 26f - - private fun populateTimeLabels( - packageName: String, - parent: RemoteViews, - containerId: Int, - hourHeightDp: Float, - palette: WidgetPalette, - periods: List, - ) { - val compact = hourHeightDp < TIME_LABEL_COMPACT_THRESHOLD_DP - for (period in periods) { - val label = RemoteViews(packageName, R.layout.widget_time_label) - label.setTextViewText(R.id.widget_time_label_number, "${period.name}.") - label.setTextViewText(R.id.widget_time_label_time, formatHm(period.startMinutes)) - if (compact) { - label.setViewVisibility(R.id.widget_time_label_time, View.GONE) - label.setTextViewTextSize( - R.id.widget_time_label_number, - TypedValue.COMPLEX_UNIT_SP, - 9f, - ) - label.setTextColor(R.id.widget_time_label_number, palette.textPrimary) - } else { - label.setViewVisibility(R.id.widget_time_label_time, View.VISIBLE) - label.setTextViewTextSize( - R.id.widget_time_label_number, - TypedValue.COMPLEX_UNIT_SP, - 7f, - ) - label.setTextColor(R.id.widget_time_label_number, palette.textSecondary) - } - label.setTextColor(R.id.widget_time_label_time, palette.textPrimary) - val topDp = period.virtualStartMinutes * hourHeightDp / 60.0f - label.setViewLayoutMargin( + private fun populateRuler(grid: GridContext, parent: RemoteViews, containerId: Int) { + populateGridLines(grid, parent, containerId) + val palette = grid.palette + for (period in grid.periods) { + val cellDp = grid.dp((period.virtualEndMinutes - period.virtualStartMinutes).toFloat()) + val label = RemoteViews(grid.packageName, R.layout.widget_time_label) + positionVertically( + label, R.id.widget_time_label_root, - RemoteViews.MARGIN_TOP, - topDp, - TypedValue.COMPLEX_UNIT_DIP, + grid.dp(period.virtualStartMinutes.toFloat()), + cellDp, ) + label.setTextViewText(R.id.widget_time_label_number, period.name) + label.setTextColor(R.id.widget_time_label_number, palette.textPrimary) + label.setTextViewTextSize( + R.id.widget_time_label_number, + TypedValue.COMPLEX_UNIT_DIP, + (cellDp * 0.3f).coerceIn(8f, 13f), + ) + val showTimes = cellDp >= RULER_TIMES_MIN_CELL_DP + for ((viewId, minutes) in listOf( + R.id.widget_time_label_start to period.startMinutes, + R.id.widget_time_label_end to period.endMinutes, + )) { + label.setViewVisibility(viewId, if (showTimes) View.VISIBLE else View.GONE) + label.setTextViewText(viewId, formatHm(minutes)) + label.setTextColor(viewId, palette.textSecondary) + label.setTextViewTextSize( + viewId, + TypedValue.COMPLEX_UNIT_DIP, + (cellDp * 0.19f).coerceIn(6f, 8.5f), + ) + } + parent.addView(containerId, label) + } + forEachBreak(grid) { topDp, heightDp -> + if (heightDp < BREAK_ICON_MIN_HEIGHT_DP) return@forEachBreak + val label = RemoteViews(grid.packageName, R.layout.widget_break_label) + positionVertically(label, R.id.widget_break_label_root, topDp, heightDp) + label.setInt(R.id.widget_break_label_icon, "setColorFilter", palette.textSecondary) parent.addView(containerId, label) } } - private fun populateGridLines( - packageName: String, - parent: RemoteViews, - containerId: Int, - hourHeightDp: Float, - palette: WidgetPalette, - periods: List, - ) { + private fun populateGridLines(grid: GridContext, parent: RemoteViews, containerId: Int) { // Lines at every period start + end, deduped by virtual minute so // adjacent periods share a line and big-break boundaries get both // upper and lower edges. val drawn = mutableSetOf() - for (period in periods) { + for (period in grid.periods) { for (virtualMin in listOf(period.virtualStartMinutes, period.virtualEndMinutes)) { if (!drawn.add(virtualMin)) continue - val line = RemoteViews(packageName, R.layout.widget_grid_line) - line.setInt(R.id.widget_grid_line_root, "setBackgroundColor", palette.divider) - val topDp = virtualMin * hourHeightDp / 60.0f + val line = RemoteViews(grid.packageName, R.layout.widget_grid_line) + line.setInt(R.id.widget_grid_line_root, "setBackgroundColor", grid.palette.divider) line.setViewLayoutMargin( R.id.widget_grid_line_root, RemoteViews.MARGIN_TOP, - topDp, + grid.dp(virtualMin.toFloat()), TypedValue.COMPLEX_UNIT_DIP, ) parent.addView(containerId, line) @@ -418,45 +654,46 @@ object WidgetRenderer { } } - /// Faint translucent block in any virtual gap between two periods — - /// only big breaks (Hofpause, Mittagspause) survive the mapper's - /// small-break collapse. - private fun populateBreakBlocks( - packageName: String, - parent: RemoteViews, - containerId: Int, - hourHeightDp: Float, - palette: WidgetPalette, - periods: List, - ) { - for (i in 0 until periods.size - 1) { - val curr = periods[i] - val next = periods[i + 1] - val virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes - if (virtualGap <= 0) continue - val block = RemoteViews(packageName, R.layout.widget_break_block) - val topDp = curr.virtualEndMinutes * hourHeightDp / 60.0f - val heightDp = virtualGap * hourHeightDp / 60.0f - block.setViewLayoutMargin( - R.id.widget_break_block_root, - RemoteViews.MARGIN_TOP, - topDp, - TypedValue.COMPLEX_UNIT_DIP, - ) - block.setViewLayoutHeight( - R.id.widget_break_block_root, - heightDp, - TypedValue.COMPLEX_UNIT_DIP, - ) + /// Tinted band with a centred icon in any virtual gap between two + /// periods — only big breaks (Hofpause, Mittagspause) survive the + /// mapper's small-break collapse. + private fun populateBreakBlocks(grid: GridContext, parent: RemoteViews, containerId: Int) { + forEachBreak(grid) { topDp, heightDp -> + val block = RemoteViews(grid.packageName, R.layout.widget_break_block) + positionVertically(block, R.id.widget_break_block_root, topDp, heightDp) block.setInt( R.id.widget_break_block_root, "setBackgroundColor", - palette.breakBlock, + grid.palette.breakBlock, + ) + block.setInt(R.id.widget_break_block_icon, "setColorFilter", grid.palette.accent) + block.setViewVisibility( + R.id.widget_break_block_icon, + if (heightDp < BREAK_ICON_MIN_HEIGHT_DP) View.GONE else View.VISIBLE, ) parent.addView(containerId, block) } } + private inline fun forEachBreak(grid: GridContext, action: (topDp: Float, heightDp: Float) -> Unit) { + for (i in 0 until grid.periods.size - 1) { + val curr = grid.periods[i] + val virtualGap = grid.periods[i + 1].virtualStartMinutes - curr.virtualEndMinutes + if (virtualGap <= 0) continue + action(grid.dp(curr.virtualEndMinutes.toFloat()), grid.dp(virtualGap.toFloat())) + } + } + + private fun positionVertically(views: RemoteViews, viewId: Int, topDp: Float, heightDp: Float) { + views.setViewLayoutMargin( + viewId, + RemoteViews.MARGIN_TOP, + topDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + views.setViewLayoutHeight(viewId, heightDp, TypedValue.COMPLEX_UNIT_DIP) + } + private fun formatHm(minutesSinceMidnight: Int): String { val h = minutesSinceMidnight / 60 val m = minutesSinceMidnight % 60 @@ -466,15 +703,15 @@ object WidgetRenderer { /// Overrides the chrome XML's `@color/widget_*` defaults when the user /// pins a fixed light/dark theme, and resizes the watermark M to match /// the current widget bounds. - private fun applyChrome(views: RemoteViews, palette: WidgetPalette, options: Bundle?) { + private fun applyChrome(views: RemoteViews, palette: WidgetPalette, size: SizeDp?) { views.setInt(R.id.widget_root, "setBackgroundColor", palette.background) views.setInt(R.id.widget_watermark, "setColorFilter", palette.textPrimary) views.setFloat(R.id.widget_watermark, "setAlpha", palette.watermarkAlpha) - val (widthDp, heightDp) = widgetSizeDp(options) // Sized to the longer edge so the M scales with widget resize. // Negative end/bottom margin lets a sliver tuck behind the edge. - val markSize = (max(widthDp, heightDp) * 0.8f).coerceIn(160f, 400f) + val longerEdge = size?.let { max(it.width, it.height) } ?: 220f + val markSize = (longerEdge * 0.8f).coerceIn(160f, 400f) val offsetEnd = -(markSize * 0.18f) val offsetBottom = -(markSize * 0.18f) views.setViewLayoutWidth( @@ -501,29 +738,11 @@ object WidgetRenderer { ) } - private fun widgetSizeDp(options: Bundle?): Pair { - if (options == null) return Pair(220, 220) - val width = max( - options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0), - options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0), - ).coerceAtLeast(140) - val height = max( - options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0), - options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0), - ).coerceAtLeast(140) - return Pair(width, height) - } - private fun addLessonBlock( - context: Context, - packageName: String, + grid: GridContext, parent: RemoteViews, containerId: Int, lesson: WidgetLesson, - hourHeightDp: Float, - periods: List, - subjectOnly: Boolean, - horizontalPaddingDp: Int, ) { val cal = Calendar.getInstance() cal.time = lesson.start @@ -532,46 +751,28 @@ object WidgetRenderer { val endMinutes = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE) val durationMinutes = (endMinutes - startMinutes).coerceAtLeast(15) - val virtualStart = realMinutesToVirtual(startMinutes, periods) - val virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods) + val virtualStart = realMinutesToVirtual(startMinutes, grid.periods) + val virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, grid.periods) if (virtualEnd <= virtualStart) return // Half the gap above + half below so the grid line under the tile // stays visible. - val topDp = virtualStart * hourHeightDp / 60.0f + LESSON_GAP_DP / 2f - val heightDp = ((virtualEnd - virtualStart) * hourHeightDp / 60.0f - LESSON_GAP_DP) - .coerceAtLeast(MIN_BLOCK_HEIGHT_DP.toFloat()) + val topDp = grid.dp(virtualStart) + LESSON_GAP_DP / 2f + val heightDp = (grid.dp(virtualEnd - virtualStart) - LESSON_GAP_DP) + .coerceAtLeast(MIN_BLOCK_HEIGHT_DP) - val block = RemoteViews(packageName, R.layout.widget_lesson_block) - block.setViewLayoutMargin( + val block = RemoteViews(grid.packageName, R.layout.widget_lesson_block) + positionVertically(block, R.id.widget_lesson_block_root, topDp, heightDp) + block.setColorStateList( R.id.widget_lesson_block_root, - RemoteViews.MARGIN_TOP, - topDp, - TypedValue.COMPLEX_UNIT_DIP, + "setBackgroundTintList", + ColorStateList.valueOf(lesson.color ?: fallbackColor(lesson)), ) - block.setViewLayoutHeight( - R.id.widget_lesson_block_root, - heightDp, - TypedValue.COMPLEX_UNIT_DIP, - ) - block.setInt( - R.id.widget_lesson_block_root, - "setBackgroundResource", - statusDrawable(lesson), + block.setViewVisibility( + R.id.widget_lesson_cancelled, + if (lesson.status == WidgetLessonStatus.CANCELLED) View.VISIBLE else View.GONE, ) - val density = context.resources.displayMetrics.density - val padXPx = (horizontalPaddingDp * density).toInt() - val padYPx = (3 * density).toInt() - block.setViewPadding( - R.id.widget_lesson_block_root, - padXPx, padYPx, padXPx, padYPx, - ) - - block.setTextViewText(R.id.widget_lesson_subject, subjectLabel(lesson)) - - // Separate fixed-size badge so the +N hint stays readable when - // autoSize shrinks the subject on narrow tiles. if (lesson.siblingCount > 0) { block.setTextViewText( R.id.widget_lesson_sibling_badge, @@ -582,82 +783,140 @@ object WidgetRenderer { block.setViewVisibility(R.id.widget_lesson_sibling_badge, View.GONE) } - val room = roomLabel(lesson) - val teacher = teacherLabel(lesson) - val noSecondaryContent = room.isNullOrEmpty() && teacher.isNullOrEmpty() - val hideSecondary = subjectOnly || - heightDp < BLOCK_SHOW_ROOM_MIN_DP || - noSecondaryContent - block.setViewVisibility( - R.id.widget_lesson_secondary_stack, - if (hideSecondary) View.GONE else View.VISIBLE, - ) - // Custom-events have no room/teacher → let the subject wrap to 2 lines - // so long titles don't autoshrink to nothing. - block.setInt( - R.id.widget_lesson_subject, - "setMaxLines", - if (noSecondaryContent) 2 else 1, - ) - when { - hideSecondary -> { - applyOptionalText(block, R.id.widget_lesson_room, null) - applyOptionalText(block, R.id.widget_lesson_teacher, null) - } - heightDp < BLOCK_SHOW_TEACHER_SEPARATE_MIN_DP -> { - applyOptionalText(block, R.id.widget_lesson_room, room) - applyOptionalText(block, R.id.widget_lesson_teacher, null) - } - else -> { - applyOptionalText(block, R.id.widget_lesson_room, room) - applyOptionalText(block, R.id.widget_lesson_teacher, teacher) - } - } - + applyTileText(grid, block, lesson, heightDp - 2 * TILE_PADDING_V_DP) parent.addView(containerId, block) } - private fun applyOptionalText(views: RemoteViews, viewId: Int, text: String?) { - if (text.isNullOrEmpty()) { - views.setViewVisibility(viewId, View.GONE) + /// Distributes the tile height like the in-app _TileContent: the title + /// comes first, then as many body lines as fit. The line boxes are what + /// caps the font size — autoSize fills each box and shrinks a line only + /// when its text is wider than the tile. + private fun applyTileText( + grid: GridContext, + block: RemoteViews, + lesson: WidgetLesson, + innerHeightDp: Float, + ) { + val metrics = grid.metrics + val title = subjectLabel(lesson) + val body = listOfNotNull(lesson.room, teacherLabel(lesson)).filter { it.isNotBlank() } + + var titleBoxDp = min(metrics.titleBoxDp, innerHeightDp) + val bodyCapacity = floor((innerHeightDp - titleBoxDp) / metrics.bodyBoxDp + 0.05f) + .toInt() + .coerceIn(0, metrics.maxBodyLines) + val shownBody = when { + bodyCapacity >= body.size -> body + // Rather share one line than drop the teacher entirely. + bodyCapacity == 1 -> listOf(body.joinToString(" · ")) + else -> emptyList() + } + // No room for a line below the title: the room moves next to it, + // unless the title already names it ("Hof Aufsicht"). + val inline = if (bodyCapacity == 0) { + body.firstOrNull()?.takeUnless { title.contains(it) } } else { - views.setTextViewText(viewId, text) - views.setViewVisibility(viewId, View.VISIBLE) + null + } + + val spareDp = innerHeightDp - titleBoxDp - shownBody.size * metrics.bodyBoxDp + val wrappedLineDp = if (spareDp >= titleBoxDp) wrappedTitleLineBox(grid, title) else null + if (wrappedLineDp != null) titleBoxDp = 2 * wrappedLineDp + + // Keep the last line clear of the "+N" badge when it reaches down + // into the badge's corner. + val contentDp = titleBoxDp + shownBody.size * metrics.bodyBoxDp + val badgeInsetPx = if ( + lesson.siblingCount > 0 && innerHeightDp - contentDp < BADGE_HEIGHT_DP + ) { + (BADGE_WIDTH_DP * grid.context.resources.displayMetrics.density).toInt() + } else { + 0 + } + val lastLineId = listOf( + R.id.widget_lesson_title_row, + R.id.widget_lesson_room, + R.id.widget_lesson_teacher, + )[shownBody.size] + block.setViewPadding(lastLineId, 0, 0, badgeInsetPx, 0) + + if (inline != null) { + block.setViewVisibility(R.id.widget_lesson_inline, View.VISIBLE) + block.setTextViewText(R.id.widget_lesson_inline, inline) + block.setTextViewTextSize( + R.id.widget_lesson_inline, + TypedValue.COMPLEX_UNIT_DIP, + metrics.bodyFontDp, + ) + } else { + block.setViewVisibility(R.id.widget_lesson_inline, View.GONE) + } + + block.setTextViewText(R.id.widget_lesson_subject, title) + block.setInt( + R.id.widget_lesson_subject, + "setMaxLines", + if (wrappedLineDp != null) 2 else 1, + ) + block.setViewLayoutHeight( + R.id.widget_lesson_subject, + titleBoxDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + for ((index, viewId) in listOf( + R.id.widget_lesson_room, + R.id.widget_lesson_teacher, + ).withIndex()) { + val text = shownBody.getOrNull(index) + if (text == null) { + block.setViewVisibility(viewId, View.GONE) + continue + } + block.setViewVisibility(viewId, View.VISIBLE) + block.setTextViewText(viewId, text) + block.setViewLayoutHeight(viewId, metrics.bodyBoxDp, TypedValue.COMPLEX_UNIT_DIP) } } - /// Custom-events use the user-picked palette (orange/red/green/blue, - /// mirroring CustomTimetableColors). - private fun statusDrawable(lesson: WidgetLesson): Int { - if (lesson.status == WidgetLessonStatus.EVENT && lesson.customColor != null) { - return when (lesson.customColor) { - "orange" -> R.drawable.widget_lesson_block_event_orange - "red" -> R.drawable.widget_lesson_block_event_red - "green" -> R.drawable.widget_lesson_block_event_green - "blue" -> R.drawable.widget_lesson_block_event_blue - else -> R.drawable.widget_lesson_block_event_orange - } - } - return when (lesson.status) { - WidgetLessonStatus.CANCELLED -> R.drawable.widget_lesson_block_cancelled - WidgetLessonStatus.IRREGULAR -> R.drawable.widget_lesson_block_irregular - WidgetLessonStatus.TEACHER_CHANGED -> R.drawable.widget_lesson_block_teacher_changed - WidgetLessonStatus.PAST -> R.drawable.widget_lesson_block_past - WidgetLessonStatus.EVENT -> R.drawable.widget_lesson_block_event_orange - // ONGOING collapses into REGULAR — widgets only refresh every - // ~30min so "the current lesson" is stale most of the time and - // the visual highlight would mislead more than help. - WidgetLessonStatus.ONGOING, - WidgetLessonStatus.REGULAR -> R.drawable.widget_lesson_block_regular + /// Line box for a title wrapped onto two lines, or null 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 — autoSize would otherwise pick a larger font and break + /// mid-word. + private fun wrappedTitleLineBox(grid: GridContext, title: String): Float? { + val widthDp = (grid.tileInnerWidthDp ?: return null) * WIDTH_ESTIMATE_SAFETY + val paint = TextPaint().apply { + typeface = titleTypeface + textSize = 100f } + fun widthPerFontDp(text: String) = paint.measureText(text) / 100f + if (widthPerFontDp(title) * TITLE_WRAP_BELOW_FONT_DP <= widthDp) return null + val words = title.split(Regex("(?<=[\\s-])")).map { it.trim() }.filter { it.isNotEmpty() } + if (words.size < 2) return null + val longestWordFontDp = widthDp / words.maxOf { widthPerFontDp(it) } + return min(grid.metrics.titleBoxDp, longestWordFontDp * LINE_BOX_PER_FONT) + } + + /// Only reached for payloads written before Dart shipped the resolved + /// tile colour; mirrors lesson_color.dart. + private fun fallbackColor(lesson: WidgetLesson): Int = when (lesson.status) { + WidgetLessonStatus.CANCELLED -> 0xFF000000.toInt() + WidgetLessonStatus.IRREGULAR -> 0xFF8F19B3.toInt() + WidgetLessonStatus.TEACHER_CHANGED -> 0xFF29639B.toInt() + WidgetLessonStatus.DUTY -> 0xFF00796B.toInt() + WidgetLessonStatus.EVENT -> + if (lesson.customColor != null) 0xFFFFA000.toInt() else 0xFF2E7D32.toInt() + // No time-based highlight: the widget only refreshes every ~30min, + // so "current" and "past" would be stale most of the time. + WidgetLessonStatus.PAST, + WidgetLessonStatus.ONGOING, + WidgetLessonStatus.REGULAR -> 0xFF993333.toInt() } private fun subjectLabel(lesson: WidgetLesson): String { return lesson.subjectShort.ifEmpty { lesson.subjectLong ?: "—" } } - private fun roomLabel(lesson: WidgetLesson): String? = lesson.room - private fun teacherLabel(lesson: WidgetLesson): String? = lesson.teacher ?: lesson.originalTeacher diff --git a/android/app/src/main/res/drawable/widget_icon_coffee.xml b/android/app/src/main/res/drawable/widget_icon_coffee.xml new file mode 100644 index 0000000..e02dade --- /dev/null +++ b/android/app/src/main/res/drawable/widget_icon_coffee.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_icon_event.xml b/android/app/src/main/res/drawable/widget_icon_event.xml new file mode 100644 index 0000000..0a601af --- /dev/null +++ b/android/app/src/main/res/drawable/widget_icon_event.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_icon_restaurant.xml b/android/app/src/main/res/drawable/widget_icon_restaurant.xml new file mode 100644 index 0000000..bc8913f --- /dev/null +++ b/android/app/src/main/res/drawable/widget_icon_restaurant.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block.xml b/android/app/src/main/res/drawable/widget_lesson_block.xml new file mode 100644 index 0000000..0f4e532 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml b/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml deleted file mode 100644 index 7427de0..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml deleted file mode 100644 index f50c66a..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml deleted file mode 100644 index bedb7ac..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml deleted file mode 100644 index 1cbb295..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml b/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml deleted file mode 100644 index b9bfdc5..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml b/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml deleted file mode 100644 index 2bdab1c..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_past.xml b/android/app/src/main/res/drawable/widget_lesson_block_past.xml deleted file mode 100644 index b25ab7c..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_past.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_regular.xml b/android/app/src/main/res/drawable/widget_lesson_block_regular.xml deleted file mode 100644 index ca78a54..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_regular.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml b/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml deleted file mode 100644 index 58cb161..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_cancelled_overlay.xml b/android/app/src/main/res/drawable/widget_lesson_cancelled_overlay.xml new file mode 100644 index 0000000..ed82b67 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_cancelled_overlay.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml b/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml index d396613..b9a7ffe 100644 --- a/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml +++ b/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml @@ -5,7 +5,7 @@ android:viewportWidth="100" android:viewportHeight="100"> diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_red.xml b/android/app/src/main/res/drawable/widget_today_circle.xml similarity index 51% rename from android/app/src/main/res/drawable/widget_lesson_block_event_red.xml rename to android/app/src/main/res/drawable/widget_today_circle.xml index 9576750..34f2dd4 100644 --- a/android/app/src/main/res/drawable/widget_lesson_block_event_red.xml +++ b/android/app/src/main/res/drawable/widget_today_circle.xml @@ -1,6 +1,5 @@ - - + android:shape="oval"> + diff --git a/android/app/src/main/res/layout/widget_break_block.xml b/android/app/src/main/res/layout/widget_break_block.xml index 8cc7900..6804e0f 100644 --- a/android/app/src/main/res/layout/widget_break_block.xml +++ b/android/app/src/main/res/layout/widget_break_block.xml @@ -3,6 +3,14 @@ android:id="@+id/widget_break_block_root" android:layout_width="match_parent" android:layout_height="14dp" - android:layout_marginTop="0dp" - android:layout_marginStart="2dp" - android:layout_marginEnd="2dp" /> + android:layout_marginTop="0dp"> + + + diff --git a/android/app/src/main/res/layout/widget_break_label.xml b/android/app/src/main/res/layout/widget_break_label.xml new file mode 100644 index 0000000..505cf4d --- /dev/null +++ b/android/app/src/main/res/layout/widget_break_label.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/layout/widget_day.xml b/android/app/src/main/res/layout/widget_day.xml index 841d14e..32cf8c5 100644 --- a/android/app/src/main/res/layout/widget_day.xml +++ b/android/app/src/main/res/layout/widget_day.xml @@ -1,16 +1,18 @@ + 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. --> + android:background="@drawable/app_widget_background" + tools:ignore="SpUsage,SmallSp"> - + + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="10dp" + android:paddingBottom="8dp"> + + + + + android:background="@color/widget_background" + tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint"> + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="10dp" + android:paddingBottom="8dp"> - - - - + android:layout_height="20dp" + android:gravity="start|center_vertical" + android:includeFontPadding="false" + android:textSize="14dp" + android:textStyle="bold" + android:textColor="@color/widget_text_primary" + android:maxLines="1" + android:text="Heute · 06.05." /> + android:layout_marginTop="4dp" + android:orientation="horizontal"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + - - - + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_holiday_column.xml b/android/app/src/main/res/layout/widget_holiday_column.xml new file mode 100644 index 0000000..130df81 --- /dev/null +++ b/android/app/src/main/res/layout/widget_holiday_column.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_lesson_block.xml b/android/app/src/main/res/layout/widget_lesson_block.xml index 8cdbb39..e163555 100644 --- a/android/app/src/main/res/layout/widget_lesson_block.xml +++ b/android/app/src/main/res/layout/widget_lesson_block.xml @@ -1,90 +1,113 @@ + + android:background="@drawable/widget_lesson_block" + tools:ignore="SpUsage,SmallSp"> + android:orientation="vertical" + android:paddingStart="4dp" + android:paddingEnd="4dp" + android:paddingTop="2dp" + android:paddingBottom="2dp"> + + + + + + + + + android:autoSizeMinTextSize="5.5dp" + android:autoSizeMaxTextSize="10dp" + android:autoSizeStepGranularity="0.5dp" + tools:text="A12" /> - - - - - - + + + - + + tools:ignore="SpUsage,SmallSp"> + + - + android:maxLines="1" + android:includeFontPadding="false" + tools:text="08:40" /> + diff --git a/android/app/src/main/res/layout/widget_week.xml b/android/app/src/main/res/layout/widget_week.xml index da2babb..5a4b688 100644 --- a/android/app/src/main/res/layout/widget_week.xml +++ b/android/app/src/main/res/layout/widget_week.xml @@ -1,11 +1,18 @@ + + android:background="@drawable/app_widget_background" + tools:ignore="SpUsage,SmallSp"> + + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="10dp" + android:paddingBottom="8dp"> + android:layout_height="32dp" + android:layout_marginTop="4dp" + android:orientation="horizontal"> + android:layout_width="30dp" + android:layout_height="match_parent" /> @@ -123,7 +141,7 @@ + + tools:ignore="SpUsage,SmallSp"> + android:fontFamily="sans-serif-medium" + android:textSize="9dp" + android:textColor="@color/widget_text_secondary" + android:maxLines="1" + android:includeFontPadding="false" + tools:text="MO." /> + android:layout_width="18dp" + android:layout_height="18dp" + android:layout_marginTop="1dp" + android:gravity="center" + android:textSize="11dp" + android:textColor="@color/widget_text_primary" + android:maxLines="1" + android:includeFontPadding="false" + tools:text="6" /> diff --git a/android/app/src/main/res/layout/widget_week_preview.xml b/android/app/src/main/res/layout/widget_week_preview.xml index 821eaaf..ba90ce6 100644 --- a/android/app/src/main/res/layout/widget_week_preview.xml +++ b/android/app/src/main/res/layout/widget_week_preview.xml @@ -1,10 +1,11 @@ - + + android:background="@color/widget_background" + tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint"> + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="10dp" + android:paddingBottom="8dp"> + + - - - - - - - + android:layout_height="32dp" + android:layout_marginTop="4dp" + android:orientation="horizontal"> + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + android:layout_marginTop="2dp" + android:orientation="horizontal"> - - - - - - - - - - - + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values-night/widget_colors.xml b/android/app/src/main/res/values-night/widget_colors.xml index d1856c7..4074646 100644 --- a/android/app/src/main/res/values-night/widget_colors.xml +++ b/android/app/src/main/res/values-night/widget_colors.xml @@ -4,4 +4,6 @@ #FFF1F1F1 #FFB0B0B0 #33FFFFFF + #FFFFB3AF + #32FFB3AF diff --git a/android/app/src/main/res/values-v31/widget_support.xml b/android/app/src/main/res/values-v31/widget_support.xml new file mode 100644 index 0000000..dc1b9fd --- /dev/null +++ b/android/app/src/main/res/values-v31/widget_support.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7d0ae4c..db7fd9b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Stundenplan und Vertretungen für die ganze Schulwoche. Keine Stunden Ferien + Schulfrei Bitte einloggen, um den Stundenplan zu laden Lade… Stand: %1$s diff --git a/android/app/src/main/res/values/widget_colors.xml b/android/app/src/main/res/values/widget_colors.xml index db5e003..a653f1a 100644 --- a/android/app/src/main/res/values/widget_colors.xml +++ b/android/app/src/main/res/values/widget_colors.xml @@ -1,17 +1,9 @@ - - #FF993333 - #FFC83333 - #FF993333 - #FF000000 - #FF8F19B3 - #FF29639B - #FF2E7D32 - #FFFCF7F5 #FF111111 #FF555555 #22000000 + #FF904A47 + #32904A47 diff --git a/android/app/src/main/res/values/widget_support.xml b/android/app/src/main/res/values/widget_support.xml new file mode 100644 index 0000000..82b7382 --- /dev/null +++ b/android/app/src/main/res/values/widget_support.xml @@ -0,0 +1,7 @@ + + + + false + diff --git a/ios/PUSH_NSE_SETUP.md b/ios/PUSH_NSE_SETUP.md index 6bddbd4..b9b4d7d 100644 --- a/ios/PUSH_NSE_SETUP.md +++ b/ios/PUSH_NSE_SETUP.md @@ -180,11 +180,11 @@ AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = ` + | `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) | | `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart | | `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart | -| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) | +| `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) | | `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) | | `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) | -> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage +> `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers > **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE > unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei > jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift index eb61b78..6c8a831 100644 --- a/ios/Runner/SceneDelegate.swift +++ b/ios/Runner/SceneDelegate.swift @@ -13,21 +13,44 @@ class SceneDelegate: FlutterSceneDelegate { options connectionOptions: UIScene.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( UIApplication.shared, didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url] ) + handledShare = true } + if handledShare { clearSharedPayload() } } override func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - for context in URLContexts { + let shares = URLContexts.filter { isShareUrl($0.url) } + for context in shares { _ = ReceiveSharingIntentPlugin.instance.application( UIApplication.shared, open: context.url, options: [:] ) } + if !shares.isEmpty { clearSharedPayload() } + let others = URLContexts.subtracting(shares) + if !others.isEmpty { super.scene(scene, openURLContexts: others) } + } + + private func isShareUrl(_ url: URL) -> Bool { + ReceiveSharingIntentPlugin.instance.hasMatchingSchemePrefix(url: url) + } + + // The plugin keeps the payload in memory once read but never removes it + // from the App Group defaults. The Share Extension opens the app twice per + // share and a scene reconnect replays the URL, so every later delivery + // would publish the finished share again. + private func clearSharedPayload() { + let groupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String + let defaults = UserDefaults( + suiteName: groupId ?? "group.\(Bundle.main.bundleIdentifier ?? "")") + defaults?.removeObject(forKey: "ShareKey") + defaults?.removeObject(forKey: "ShareMessageKey") } } diff --git a/ios/Share Extension/ShareViewController.swift b/ios/Share Extension/ShareViewController.swift index 881b42f..a54b551 100644 --- a/ios/Share Extension/ShareViewController.swift +++ b/ios/Share Extension/ShareViewController.swift @@ -152,17 +152,54 @@ final class ShareViewController: UIViewController { } } - private func copyIntoAppGroup(src: URL) -> URL? { + // loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell. + private let fileQueue = DispatchQueue(label: "share.files") + + // Ein Ordner pro Share: Gleichnamige Dateien (z. B. bearbeitete Fotos, alle + // "FullSizeRender.jpg") überschrieben sich sonst gegenseitig, auch über + // zwei Shares hinweg. Die App löscht den Ordner, sobald der Share erledigt ist. + private lazy var shareDir: URL? = { guard let container = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else { return nil } - let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent - let dst = container.appendingPathComponent(name) + let root = container.appendingPathComponent("share_intent", isDirectory: true) + sweepStaleShares(in: root) + let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true) + do { + 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 { - if FileManager.default.fileExists(atPath: dst.path) { - try FileManager.default.removeItem(at: dst) - } try FileManager.default.copyItem(at: src, to: dst) return dst } catch { @@ -171,10 +208,9 @@ final class ShareViewController: UIViewController { } private func writePng(image: UIImage) -> URL? { - guard let container = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: appGroupId), - let data = image.pngData() else { return nil } - let dst = container.appendingPathComponent("\(UUID().uuidString).png") + guard let data = image.pngData(), + let dir = fileQueue.sync(execute: { shareDir }) else { return nil } + let dst = dir.appendingPathComponent("\(UUID().uuidString).png") return (try? data.write(to: dst)) != nil ? dst : nil } diff --git a/ios/TimetableWidgetExtension/TimetableDayView.swift b/ios/TimetableWidgetExtension/TimetableDayView.swift index 7ab20e3..1817a33 100644 --- a/ios/TimetableWidgetExtension/TimetableDayView.swift +++ b/ios/TimetableWidgetExtension/TimetableDayView.swift @@ -1,59 +1,6 @@ import SwiftUI import WidgetKit -// Layout constants — must mirror WidgetRenderer.kt on Android, otherwise -// the platforms drift apart on the same widget size. -let FALLBACK_VIRTUAL_MINUTES = 11 * 60 -let MIN_HOUR_HEIGHT: CGFloat = 18 -let MAX_HOUR_HEIGHT: CGFloat = 72 -let MIN_BLOCK_HEIGHT: CGFloat = 16 -let LESSON_GAP: CGFloat = 1.5 - -func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat { - guard !periods.isEmpty else { return CGFloat(realMin) } - for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes { - return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes)) - } - let first = periods.first! - if realMin < first.startMinutes { - return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes) - } - let last = periods.last! - if realMin > last.endMinutes { - return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes)) - } - var prev = first - for i in 1.. prev.endMinutes && realMin < curr.startMinutes { - let gap = curr.startMinutes - prev.endMinutes - let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes - if gap > 0 { - return CGFloat(prev.virtualEndMinutes) + - CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap) - } - return CGFloat(curr.virtualStartMinutes) - } - prev = curr - } - return 0 -} - -// Below these tile heights the secondary stack collapses progressively: -// shorter than ROOM_MIN drops everything, between ROOM_MIN and -// TEACHER_SEPARATE_MIN keeps the room but drops the teacher. -let BLOCK_SHOW_ROOM_MIN: CGFloat = 13 -let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 20 - -let MIN_SUBJECT_FONT: CGFloat = 9 -let MAX_SUBJECT_FONT: CGFloat = 14 -let MIN_SECONDARY_FONT: CGFloat = 7 - -func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat { - let t = max(0, min(1, (hourHeight - MIN_HOUR_HEIGHT) / (MAX_HOUR_HEIGHT - MIN_HOUR_HEIGHT))) - return MIN_SUBJECT_FONT + t * (MAX_SUBJECT_FONT - MIN_SUBJECT_FONT) -} - struct TimetableDayView: View { let entry: TimetableEntry @Environment(\.widgetPalette) private var palette @@ -73,7 +20,7 @@ struct TimetableDayView: View { @ViewBuilder private func content(data: WidgetTimetableData) -> some View { - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) { header(data: data) if data.isHoliday { emptyState(text: data.holidayName ?? "Ferien") @@ -81,37 +28,41 @@ struct TimetableDayView: View { emptyState(text: "Keine Stunden") } else { GeometryReader { geo in - let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) - TimeGridView( - lessons: data.lessons, - periods: data.periods, - hourHeight: max( - MIN_HOUR_HEIGHT, - min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) - ), - showRoom: true, - showTeacher: true, - showTimeLabels: true - ) + let periods = trimTrailingPeriods(data.periods, lessons: data.lessons) + HStack(alignment: .top, spacing: 0) { + PeriodRuler( + periods: periods, + hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods) + ) + Rectangle() + .fill(palette.divider) + .frame(width: 1) + TimeGridView( + lessons: data.lessons, + periods: periods, + hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods) + ) + } } } } } private func header(data: WidgetTimetableData) -> some View { - HStack(spacing: 4) { + HStack(spacing: 6) { Text(dayLabel(for: data.anchorDate, relativeTo: entry.date)) - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 14, weight: .bold)) .foregroundStyle(palette.textPrimary) .lineLimit(1) - .minimumScaleFactor(0.7) - Spacer(minLength: 4) + .minimumScaleFactor(0.65) + .frame(maxWidth: .infinity, alignment: .leading) Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") - .font(.system(size: 9)) + .font(.system(size: 10)) .foregroundStyle(palette.textSecondary) .lineLimit(1) - .minimumScaleFactor(0.7) + .fixedSize() } + .frame(height: TITLE_ROW_HEIGHT) } private func emptyState(text: String) -> some View { @@ -139,54 +90,23 @@ struct TimetableDayView: View { } } -struct TimeGridView: View { - let lessons: [WidgetLesson] +/// Mirrors the in-app _PeriodLabel: period number centred, start time above, +/// end time below, a cup in the breaks. +struct PeriodRuler: View { let periods: [WidgetPeriod] let hourHeight: CGFloat - let showRoom: Bool - let showTeacher: Bool - let showTimeLabels: Bool - /// Week-widget passes 3 for narrow columns; day-widget keeps 7. - var horizontalPadding: CGFloat = 7 @Environment(\.widgetPalette) private var palette - private var totalVirtualMinutes: Int { - periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES - } - private var totalHeight: CGFloat { - CGFloat(totalVirtualMinutes) * hourHeight / 60.0 + CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0 } - /// Below this per-hour height the two-line label collapses to a single - /// period number — time + number overlap otherwise. - private var compactLabels: Bool { hourHeight < 26 } - var body: some View { - HStack(alignment: .top, spacing: 0) { - if showTimeLabels { - // 28pt matches the Week widget's time-label column so the - // visual rhythm is identical across both widgets. - timeLabelsColumn - .frame(width: 28, alignment: .topTrailing) - Rectangle() - .fill(palette.divider) - .frame(width: 1) - } - ZStack(alignment: .top) { - gridLines - breakBlocks - ForEach(lessons.indices, id: \.self) { idx in - lessonBlock(lessons[idx]) - } - } - .frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top) - } - } - - private var timeLabelsColumn: some View { - ZStack(alignment: .topTrailing) { + // Every child has layout-y 0 (`.offset` only shifts the rendering), + // so both the ZStack and its frame need `.top` — the default + // `.center` would anchor the whole ruler at the column's midpoint. + ZStack(alignment: .top) { ForEach(periodBoundaries(periods), id: \.self) { virtualMin in Rectangle() .fill(palette.divider) @@ -194,39 +114,91 @@ struct TimeGridView: View { .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) } ForEach(periods, id: \.startMinutes) { period in - VStack(alignment: .trailing, spacing: -2) { - if compactLabels { - Text("\(period.name).") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - } else { - Text(formatHm(period.startMinutes)) - .font(.system(size: 9)) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - Text("\(period.name).") - .font(.system(size: 7, weight: .bold)) - .foregroundStyle(palette.textSecondary) - .lineLimit(1) - } + cell(period) + } + ForEach(periodBreaks(periods), id: \.self) { pause in + let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0 + if height >= BREAK_ICON_MIN_HEIGHT { + Image(systemName: "cup.and.saucer") + .font(.system(size: 8)) + .foregroundStyle(palette.textSecondary) + .frame(width: RULER_WIDTH, height: height) + .offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0) } - .padding(.trailing, 4) - .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) } } - .frame(height: totalHeight, alignment: .topTrailing) + .frame(width: RULER_WIDTH, height: totalHeight, alignment: .top) + } + + private func cell(_ period: WidgetPeriod) -> some View { + let height = CGFloat(period.virtualEndMinutes - period.virtualStartMinutes) * hourHeight / 60.0 + let timeFont = max(6, min(8.5, height * 0.19)) + return ZStack { + Text(period.name) + .font(.system(size: max(8, min(13, height * 0.3)), weight: .medium)) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + if height >= RULER_TIMES_MIN_CELL { + VStack(spacing: 0) { + Text(formatHm(period.startMinutes)) + Spacer(minLength: 0) + Text(formatHm(period.endMinutes)) + } + .font(.system(size: timeFont)) + .foregroundStyle(palette.textSecondary) + .lineLimit(1) + .padding(.vertical, 2) + } + } + .frame(width: RULER_WIDTH, height: height) + .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) + } + + private func formatHm(_ minutes: Int) -> String { + String(format: "%02d:%02d", minutes / 60, minutes % 60) + } +} + +/// One day column: grid lines, break bands and the lesson tiles. Shared by +/// the day widget and each of the week widget's five columns. +struct TimeGridView: View { + let lessons: [WidgetLesson] + let periods: [WidgetPeriod] + let hourHeight: CGFloat + var holiday: WidgetDayInfo? = nil + var isToday: Bool = false + + @Environment(\.widgetPalette) private var palette + + private var totalHeight: CGFloat { + CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0 + } + + var body: some View { + GeometryReader { geo in + let metrics = TileMetrics(periods: periods, hourHeight: hourHeight) + let innerWidth = geo.size.width - 2 * (TILE_MARGIN_H + TILE_PADDING_H) + ZStack(alignment: .top) { + if let holiday { + holidayColumn(holiday) + } else { + if isToday { + palette.todayTint.frame(height: totalHeight) + } + gridLines + breakBlocks + } + ForEach(lessons.indices, id: \.self) { idx in + lessonBlock(lessons[idx], metrics: metrics, innerWidth: innerWidth) + } + } + .frame(width: geo.size.width, height: totalHeight, alignment: .top) + } + .frame(height: totalHeight) } private var gridLines: some View { - // `.frame(height: totalHeight, alignment: .top)` — without the - // explicit `.top`, SwiftUI's frame modifier defaults to `.center`. - // Every Rectangle here has layout-y=0 (the `.offset()`s shift the - // rendered position only, not layout), so the inner ZStack's - // natural height is just 1pt; a 1pt ZStack centred in a 300pt - // frame anchors at the middle, and every line then renders - // relative to that midpoint — the grid visually starts halfway - // down the column and the later periods clip past the bottom. + // `.top` on both the ZStack and the frame — see PeriodRuler. ZStack(alignment: .top) { ForEach(periodBoundaries(periods), id: \.self) { virtualMin in Rectangle() @@ -239,166 +211,173 @@ struct TimeGridView: View { } private var breakBlocks: some View { - // Same `.center`-vs-`.top` bug as gridLines — see comment above. ZStack(alignment: .top) { - ForEach(0.. 0 { - Rectangle() - .fill(palette.breakBlock) - .frame(height: CGFloat(virtualGap) * hourHeight / 60.0) - .padding(.horizontal, 1) - .offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0) + ForEach(periodBreaks(periods), id: \.self) { pause in + let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0 + ZStack { + palette.breakBlock + if height >= BREAK_ICON_MIN_HEIGHT { + Image(systemName: "fork.knife") + .font(.system(size: 9)) + .foregroundStyle(palette.accent) + } } + .frame(height: height) + .offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0) } } .frame(height: totalHeight, alignment: .top) } - private func formatHm(_ minutes: Int) -> String { - String(format: "%02d:%02d", minutes / 60, minutes % 60) + /// Mirrors the in-app holiday region: icon and "Schulfrei" on top, the + /// holiday name rotated along the column. + private func holidayColumn(_ holiday: WidgetDayInfo) -> some View { + let nameLength = max(40, totalHeight - 2 * HOLIDAY_NAME_INSET) + return ZStack { + palette.holidayTint + VStack(spacing: 2) { + Image(systemName: "calendar") + .font(.system(size: 12)) + Text("Schulfrei") + .font(.system(size: 9)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + .padding(.top, 8) + .padding(.horizontal, 2) + .frame(maxHeight: .infinity, alignment: .top) + // The outer frame carries the rotated footprint: rotationEffect + // alone leaves the layout box at its unrotated size. + Text(holiday.holidayName ?? "") + .font(.system(size: 11, weight: .bold)) + .kerning(1) + .lineLimit(1) + .minimumScaleFactor(0.6) + .frame(width: nameLength, height: 14) + .rotationEffect(.degrees(90)) + .frame(width: 14, height: nameLength) + } + .foregroundStyle(palette.textPrimary) + .frame(height: totalHeight) } @ViewBuilder - private func lessonBlock(_ lesson: WidgetLesson) -> some View { - let cal = Calendar.current - let comps = cal.dateComponents([.hour, .minute], from: lesson.start) - let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0) + private func lessonBlock( + _ lesson: WidgetLesson, + metrics: TileMetrics, + innerWidth: CGFloat + ) -> some View { + let startMinutes = minutesOfDay(lesson.start) let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60)) let virtualStart = realMinutesToVirtual(startMinutes, periods: periods) let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods) if virtualEnd > virtualStart { + // Half the gap above + half below so the grid line under the + // tile stays visible. let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2 let height = max( MIN_BLOCK_HEIGHT, (virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP ) - let subjectSize = subjectFont(forHourHeight: hourHeight) - let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2) - let room = lesson.room - let teacher = lesson.teacher ?? lesson.originalTeacher - let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false) - HStack(alignment: .top, spacing: 4) { - Text(subjectLabel(lesson)) - .font(.system(size: subjectSize, weight: .semibold)) - .foregroundStyle(.white) - .lineLimit(hasSecondary ? 1 : 2) - .minimumScaleFactor(0.5) - if hasSecondary { - Spacer(minLength: 0) - VStack(alignment: .trailing, spacing: -1) { - if showRoom && height >= BLOCK_SHOW_ROOM_MIN { - if let room, !room.isEmpty { - Text(room) - .font(.system(size: secondarySize)) - .foregroundStyle(.white.opacity(0.85)) - .lineLimit(1) - .minimumScaleFactor(0.5) - } - if showTeacher, - height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN, - let teacher, - !teacher.isEmpty { - Text(teacher) - .font(.system(size: secondarySize)) - .foregroundStyle(.white.opacity(0.7)) - .lineLimit(1) - .minimumScaleFactor(0.5) - } - } - } - } - } - .padding(.horizontal, horizontalPadding) - .padding(.vertical, 3) - .frame(maxWidth: .infinity, alignment: .topLeading) - .frame(height: height, alignment: .topLeading) - .background(blockColor(lesson)) - .cornerRadius(6) - .overlay(alignment: .bottomLeading) { - // Separate fixed-size badge so the +N hint stays readable - // when the subject autoshrinks on narrow tiles. - if let count = lesson.siblingCount, count > 0 { - Text("+\(count)") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.white) - .padding(.leading, horizontalPadding) - .padding(.bottom, 2) - } - } - .overlay { - // CrossPainter parity: clip cross to the rounded shape so - // the diagonals don't bleed past the corners. - if lesson.status == .cancelled { - ZStack { - RoundedRectangle(cornerRadius: 6) - .stroke(Color.red.opacity(0.78), lineWidth: 1.5) - GeometryReader { geo in - Path { p in - p.move(to: .zero) - p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height)) - p.move(to: CGPoint(x: geo.size.width, y: 0)) - p.addLine(to: CGPoint(x: 0, y: geo.size.height)) - } - .stroke(Color.red.opacity(0.78), lineWidth: 3) - } - } - .clipShape(RoundedRectangle(cornerRadius: 6)) - } - } - .padding(.horizontal, showRoom ? 2 : 1) + LessonTile( + lesson: lesson, + height: height, + metrics: metrics, + layout: TileTextLayout( + lesson: lesson, + innerHeight: height - 2 * TILE_PADDING_V, + innerWidth: innerWidth, + metrics: metrics + ) + ) .offset(y: top) } } - - private func subjectLabel(_ lesson: WidgetLesson) -> String { - !lesson.subjectShort.isEmpty - ? lesson.subjectShort - : (lesson.subjectLong ?? "—") - } - - /// Mirrors lesson_color.dart + custom_event_colors.dart so the widget - /// matches the in-app calendar exactly. - private func blockColor(_ lesson: WidgetLesson) -> Color { - if lesson.status == .event, let custom = lesson.customColor { - switch custom { - case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0) - case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0) - case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0) - case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0) - default: break - } - } - switch lesson.status { - // `.ongoing` deliberately collapses into the regular red — the - // widget timeline only ticks every ~30min, so highlighting the - // "current" lesson would be stale most of the day and misleads - // more than it helps. - case .regular, .past, .ongoing: - return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0) - case .cancelled: return .black - case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0) - case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0) - case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0) - case .duty: return Color(red: 0/255.0, green: 121/255.0, blue: 107/255.0) - } - } } -/// Period boundaries deduped: adjacent periods share a line, periods on -/// either side of a break get their own (bracketing the break block). -func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] { - var seen = Set() - var result: [Int] = [] - for p in periods { - for v in [p.virtualStartMinutes, p.virtualEndMinutes] { - if seen.insert(v).inserted { result.append(v) } +/// Mirrors the in-app AppointmentTile: title on top, room and teacher +/// stacked below, each line shrinking on its own to fit the tile width. +private struct LessonTile: View { + let lesson: WidgetLesson + let height: CGFloat + let metrics: TileMetrics + let layout: TileTextLayout + + private static let cancelledRed = + Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255) + + var body: some View { + let shape = RoundedRectangle(cornerRadius: TILE_CORNER_RADIUS) + VStack(alignment: .leading, spacing: 0) { + titleRow + ForEach(Array(layout.body.enumerated()), id: \.offset) { index, line in + Text(line) + .font(.system(size: metrics.bodyFont)) + .lineLimit(1) + .minimumScaleFactor(MIN_BODY_LINE_FONT / metrics.bodyFont) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: metrics.bodyBox) + .padding(.trailing, badgeInset(line: index + 1)) + } } + .foregroundStyle(.white) + .padding(.horizontal, TILE_PADDING_H) + .padding(.vertical, TILE_PADDING_V) + .frame(maxWidth: .infinity, alignment: .topLeading) + .frame(height: height, alignment: .topLeading) + .background(lesson.tileColor) + .clipShape(shape) + .overlay(alignment: .bottomTrailing) { + if let count = lesson.siblingCount, count > 0 { + Text("+\(count)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.trailing, TILE_PADDING_H) + .padding(.bottom, TILE_PADDING_V) + } + } + .overlay { + if lesson.status == .cancelled { + ZStack { + shape.strokeBorder(LessonTile.cancelledRed, lineWidth: 2) + GeometryReader { geo in + Path { p in + p.move(to: .zero) + p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height)) + p.move(to: CGPoint(x: geo.size.width, y: 0)) + p.addLine(to: CGPoint(x: 0, y: geo.size.height)) + } + .stroke(LessonTile.cancelledRed, lineWidth: 2) + } + } + .clipShape(shape) + } + } + .padding(.horizontal, TILE_MARGIN_H) + } + + private var titleRow: some View { + HStack(alignment: .center, spacing: 3) { + Text(layout.title) + .font(.system(size: layout.titleFont, weight: .medium)) + .lineLimit(layout.titleLines) + .minimumScaleFactor(min(1, MIN_TITLE_FONT / layout.titleFont)) + .frame(maxWidth: .infinity, alignment: .leading) + if let inline = layout.inline { + Text(inline) + .font(.system(size: metrics.bodyFont)) + .lineLimit(1) + .fixedSize() + } + } + .frame(height: layout.titleBox) + .padding(.trailing, badgeInset(line: 0)) + } + + private func badgeInset(line: Int) -> CGFloat { + layout.badgeLine == line ? BADGE_WIDTH : 0 } - return result.sorted() } /// Fixed-locale formatters cached once — DateFormatter setup is the diff --git a/ios/TimetableWidgetExtension/TimetableLayout.swift b/ios/TimetableWidgetExtension/TimetableLayout.swift new file mode 100644 index 0000000..b37b28a --- /dev/null +++ b/ios/TimetableWidgetExtension/TimetableLayout.swift @@ -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.. 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() + 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.. 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) + } + } +} diff --git a/ios/TimetableWidgetExtension/TimetableWeekView.swift b/ios/TimetableWidgetExtension/TimetableWeekView.swift index 123b0d0..6a55873 100644 --- a/ios/TimetableWidgetExtension/TimetableWeekView.swift +++ b/ios/TimetableWidgetExtension/TimetableWeekView.swift @@ -20,36 +20,44 @@ struct TimetableWeekView: View { @ViewBuilder private func content(data: WidgetTimetableData) -> some View { + let cal = Calendar.current + let days = (0..<5).map { offset in + cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate + } + let weekLessons = data.lessons.filter { lesson in + days.contains { cal.isDate(lesson.start, inSameDayAs: $0) } + } + let periods = trimTrailingPeriods(data.periods, lessons: weekLessons) VStack(alignment: .leading, spacing: 0) { header(data: data) - dayHeaderRow(data: data) + dayHeaderRow(days: days) + .padding(.top, GRID_MARGIN_TOP) GeometryReader { geo in - let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) - let hourHeight = max( - MIN_HOUR_HEIGHT, - min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) - ) - let dayColumnWidth = (geo.size.width - 28 - 4) / 5 - // 45pt is the minimum where a 7pt-font room (e.g. "F2.04") - // plus the subject fits without `minimumScaleFactor` - // shrinking the subject into illegibility. - let subjectOnly = dayColumnWidth < 45 + let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods) HStack(alignment: .top, spacing: 0) { - timeLabelsColumn(hourHeight: hourHeight, periods: data.periods) - .frame(width: 28, alignment: .topTrailing) + PeriodRuler(periods: periods, hourHeight: height) columnDivider - ForEach(0..<5, id: \.self) { offset in - column( - data: data, - offset: offset, - hourHeight: hourHeight, - subjectOnly: subjectOnly + ForEach(days.indices, id: \.self) { index in + TimeGridView( + lessons: weekLessons.filter { + cal.isDate($0.start, inSameDayAs: days[index]) + }, + periods: periods, + hourHeight: height, + holiday: holiday(on: days[index], data: data), + isToday: cal.isDate(days[index], inSameDayAs: entry.date) ) - .frame(maxWidth: .infinity) - if offset < 4 { columnDivider } + if index < 4 { columnDivider } } } } + .padding(.top, 2) + } + } + + private func holiday(on day: Date, data: WidgetTimetableData) -> WidgetDayInfo? { + data.days?.first { + $0.isHoliday && Calendar.current.isDate($0.date, inSameDayAs: day) } } @@ -63,112 +71,55 @@ struct TimetableWeekView: View { let cal = Calendar.current let week = cal.component(.weekOfYear, from: data.anchorDate) let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate - return HStack { + return HStack(spacing: 6) { Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))") - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 14, weight: .bold)) .foregroundStyle(palette.textPrimary) .lineLimit(1) - .minimumScaleFactor(0.8) - Spacer() + .minimumScaleFactor(0.65) + .frame(maxWidth: .infinity, alignment: .leading) Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") - .font(.system(size: 9)) + .font(.system(size: 10)) .foregroundStyle(palette.textSecondary) .lineLimit(1) + .fixedSize() } + .frame(height: TITLE_ROW_HEIGHT) } - private func dayHeaderRow(data: WidgetTimetableData) -> some View { - let cal = Calendar.current - // `.frame(maxWidth: .infinity)` is critical: without an explicit - // greedy width (or a true `Spacer()` inside), this HStack collapses - // to the sum of its fixed-width children in a `.leading` VStack - // and the 5 day-columns end up sharing ~5pt of width — the labels - // crunch to the left and stop aligning with the grid columns below. + /// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below, + /// today inside an accent circle. + private func dayHeaderRow(days: [Date]) -> some View { // The fixed height keeps `columnDivider`'s vertically-flexible - // Rectangle from stealing space from the GeometryReader. - return HStack(spacing: 0) { - Spacer().frame(width: 28) + // Rectangle from stealing space from the GeometryReader below. + HStack(spacing: 0) { + Spacer().frame(width: RULER_WIDTH) columnDivider - ForEach(0..<5, id: \.self) { offset in - let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate - VStack(spacing: -1) { - Text(weekday(for: day)) - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(palette.textPrimary) - Text(shortDate(day)) - .font(.system(size: 8)) - .foregroundStyle(palette.textSecondary) - } - .frame(maxWidth: .infinity) - if offset < 4 { columnDivider } + ForEach(days.indices, id: \.self) { index in + dayHeaderCell(days[index]) + .frame(maxWidth: .infinity) + if index < 4 { columnDivider } } } - // 20pt fits 10pt weekday + 8pt date with tight -1pt spacing. - .frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20) + .frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT) } - private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View { - let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES - let totalHeight = CGFloat(totalMin) * hourHeight / 60.0 - // Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked - // time+name no longer fits in one period slot and the two labels - // visually clip into the next slot. Drop to a single bold period - // number until there's room to read both lines. - let compact = hourHeight < 26 - return ZStack(alignment: .topTrailing) { - ForEach(periodBoundaries(periods), id: \.self) { virtualMin in - Rectangle() - .fill(palette.divider) - .frame(height: 1) - .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) - } - ForEach(periods, id: \.startMinutes) { period in - VStack(alignment: .trailing, spacing: -2) { - if compact { - Text("\(period.name).") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - } else { - Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60)) - .font(.system(size: 9)) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - Text("\(period.name).") - .font(.system(size: 7, weight: .bold)) - .foregroundStyle(palette.textSecondary) - .lineLimit(1) - } - } - .padding(.trailing, 4) - .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) - } + private func dayHeaderCell(_ day: Date) -> some View { + // `entry.date`, not `Date()` — WidgetKit archives entries ahead of + // time, the midnight entry has to mark its own day. + let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date) + return VStack(spacing: 1) { + Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased()) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(isToday ? palette.accent : palette.textSecondary) + .lineLimit(1) + Text("\(Calendar.current.component(.day, from: day))") + .font(.system(size: 11, weight: isToday ? .bold : .regular)) + .foregroundStyle(isToday ? palette.onAccent : palette.textPrimary) + .lineLimit(1) + .frame(width: 18, height: 18) + .background(Circle().fill(isToday ? palette.accent : Color.clear)) } - .frame(height: totalHeight, alignment: .topTrailing) - } - - private func column( - data: WidgetTimetableData, - offset: Int, - hourHeight: CGFloat, - subjectOnly: Bool - ) -> some View { - let cal = Calendar.current - let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate - let lessonsForDay = data.lessons.filter { cal.isDate($0.start, inSameDayAs: day) } - return TimeGridView( - lessons: lessonsForDay, - periods: data.periods, - hourHeight: hourHeight, - showRoom: !subjectOnly, - showTeacher: !subjectOnly, - showTimeLabels: false, - horizontalPadding: 3 - ) - } - - private func weekday(for date: Date) -> String { - WidgetDateFormatters.weekdayShort.string(from: date) } private func placeholder(_ message: String) -> some View { diff --git a/ios/TimetableWidgetExtension/WidgetData.swift b/ios/TimetableWidgetExtension/WidgetData.swift index 721e8a4..6846f19 100644 --- a/ios/TimetableWidgetExtension/WidgetData.swift +++ b/ios/TimetableWidgetExtension/WidgetData.swift @@ -33,6 +33,9 @@ struct WidgetLesson: Codable { let originalTeacher: String? let status: WidgetLessonStatus let customColor: String? + // Tile colour resolved in Dart as "#RRGGBB" (subject colours, palettes); + // nil only for payloads written before the field existed. + let color: String? let siblingCount: Int? } diff --git a/ios/TimetableWidgetExtension/WidgetPalette.swift b/ios/TimetableWidgetExtension/WidgetPalette.swift index c197273..d6aafec 100644 --- a/ios/TimetableWidgetExtension/WidgetPalette.swift +++ b/ios/TimetableWidgetExtension/WidgetPalette.swift @@ -9,15 +9,27 @@ struct WidgetPalette { let textPrimary: Color let textSecondary: Color let divider: Color + let accent: Color + let onAccent: Color let breakBlock: Color + let todayTint: Color + let holidayTint: Color let watermarkOpacity: Double + // ColorScheme.fromSeed(#993333).primary of the app's light / dark theme. + private static let lightAccent = Color(red: 0x90 / 255, green: 0x4A / 255, blue: 0x47 / 255) + private static let darkAccent = Color(red: 0xFF / 255, green: 0xB3 / 255, blue: 0xAF / 255) + static let light = WidgetPalette( background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255), textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255), textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255), divider: Color.black.opacity(0x22 / 255.0), - breakBlock: Color.black.opacity(0x0C / 255.0), + accent: lightAccent, + onAccent: .white, + breakBlock: lightAccent.opacity(0x32 / 255.0), + todayTint: lightAccent.opacity(0x0E / 255.0), + holidayTint: Color.black.opacity(0x32 / 255.0), watermarkOpacity: 0.014 ) @@ -26,7 +38,11 @@ struct WidgetPalette { textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255), textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255), divider: Color.white.opacity(0x33 / 255.0), - breakBlock: Color.white.opacity(0x14 / 255.0), + accent: darkAccent, + onAccent: Color(red: 0x57 / 255, green: 0x1D / 255, blue: 0x1D / 255), + breakBlock: darkAccent.opacity(0x32 / 255.0), + todayTint: darkAccent.opacity(0x0E / 255.0), + holidayTint: Color.white.opacity(0x32 / 255.0), watermarkOpacity: 0.025 ) diff --git a/lib/api/cache_store.dart b/lib/api/cache_store.dart new file mode 100644 index 0000000..169540c --- /dev/null +++ b/lib/api/cache_store.dart @@ -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 (`\n`). +/// +/// 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> _dirs = {}; + final Map> _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() { + 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(String key) async => + File('${(await _directory()).path}/${_safeName(key)}'); + + /// Keys are internal ids (`nc-chat-`, `wd-folder-`); only path + /// separators would be a problem in a file name. + static String _safeName(String key) => key.replaceAll(RegExp(r'[/\\]'), '_'); + + Future 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> 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 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.value(); + late final Future current; + current = previous.then((_) => _write(file, json)).whenComplete(() { + if (identical(_writes[path], current)) _writes.remove(path); + }); + return _writes[path] = current; + } + + Future _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 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 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 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> 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 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 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> decode(String json) => + json.length > isolateDecodeThreshold + ? compute(_decodeMap, json) + : Future.value(_decodeMap(json)); + + static Map _decodeMap(String json) => + jsonDecode(json) as Map; + + /// 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 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'); + } + } +} diff --git a/lib/api/errors/stale_session_exception.dart b/lib/api/errors/stale_session_exception.dart new file mode 100644 index 0000000..52936e1 --- /dev/null +++ b/lib/api/errors/stale_session_exception.dart @@ -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', + ); +} diff --git a/lib/api/marianumcloud/autocomplete/autocomplete_api.dart b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart index 1423c43..0394871 100644 --- a/lib/api/marianumcloud/autocomplete/autocomplete_api.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import '../../../model/endpoint_data.dart'; import '../../errors/server_exception.dart'; +import '../../http_errors.dart'; import '../nextcloud_ocs.dart'; import 'autocomplete_response.dart'; @@ -30,7 +31,12 @@ class AutocompleteApi { 'limit': '10', }, ); - final response = await http.get(uri, headers: NextcloudOcs.headers()); + final response = (await sendGuarded( + 'Autocomplete $uri', + () => http + .get(uri, headers: NextcloudOcs.headers()) + .timeout(const Duration(seconds: 20)), + ))!; if (response.statusCode != HttpStatus.ok) { throw ServerException( statusCode: response.statusCode, diff --git a/lib/api/marianumcloud/talk/actions/talk_actions.dart b/lib/api/marianumcloud/talk/actions/talk_actions.dart index acf4d50..391c0f7 100644 --- a/lib/api/marianumcloud/talk/actions/talk_actions.dart +++ b/lib/api/marianumcloud/talk/actions/talk_actions.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import '../../../api_params.dart'; import '../../../api_response.dart'; +import '../room/get_room_response.dart'; import '../talk_api.dart'; /// Small POST/DELETE-only Talk endpoints that have no response payload. @@ -105,3 +106,21 @@ class DeleteRoomAvatar extends TalkApi { Map? 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 request( + Uri uri, + ApiParams? body, + Map? headers, + ) => http.post(uri, headers: headers, body: {'level': '${level.apiValue}'}); +} diff --git a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart index 1a99d58..2f224cf 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart @@ -17,7 +17,7 @@ class GetChatCache extends SimpleCache { lookIntoFuture: GetChatParamsSwitch.off, setReadMarker: GetChatParamsSwitch.on, // 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. limit: 50, ), diff --git a/lib/api/marianumcloud/talk/chat/get_chat_history.dart b/lib/api/marianumcloud/talk/chat/get_chat_history.dart index ab038dd..dad1d84 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat_history.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_history.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../../../errors/server_exception.dart'; @@ -45,8 +46,10 @@ class GetChatHistory { final status = response.statusCode; if (status == 304) return null; if (status >= 200 && status < 300) { - return GetChatResponse.fromJson(NextcloudOcs.decode(response.body)) - ..headers = response.headers; + // A page holds up to 200 messages and lands while the user is scrolling; + // decoding it on the UI isolate stalls the fling. + final parsed = await compute(_parseChatResponse, response.body); + return parsed..headers = response.headers; } throw ServerException( statusCode: status, @@ -54,3 +57,6 @@ class GetChatHistory { ); } } + +GetChatResponse _parseChatResponse(String body) => + GetChatResponse.fromJson(NextcloudOcs.decode(body)); diff --git a/lib/api/marianumcloud/talk/room/get_room.dart b/lib/api/marianumcloud/talk/room/get_room.dart index 14df26a..8c9d01f 100644 --- a/lib/api/marianumcloud/talk/room/get_room.dart +++ b/lib/api/marianumcloud/talk/room/get_room.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../../nextcloud_ocs.dart'; @@ -10,8 +11,12 @@ class GetRoom extends TalkApi { GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson()); @override - GetRoomResponse assemble(String raw) => - GetRoomResponse.fromJson(NextcloudOcs.decode(raw)); + GetRoomResponse assemble(String raw) => _parseRooms(raw); + + // The room list with status is 100–250 KB and polled every 15–60 s. + @override + Future assembleAsync(String raw) => + compute(_parseRooms, raw); @override Future request( @@ -20,3 +25,6 @@ class GetRoom extends TalkApi { Map? headers, ) => http.get(uri, headers: headers); } + +GetRoomResponse _parseRooms(String raw) => + GetRoomResponse.fromJson(NextcloudOcs.decode(raw)); diff --git a/lib/api/marianumcloud/talk/room/get_room_response.dart b/lib/api/marianumcloud/talk/room/get_room_response.dart index b720c65..5610efa 100644 --- a/lib/api/marianumcloud/talk/room/get_room_response.dart +++ b/lib/api/marianumcloud/talk/room/get_room_response.dart @@ -130,6 +130,12 @@ enum GetRoomResponseObjectParticipantNotificationLevel { neverNotify, } +extension NotificationLevelApiValue + on GetRoomResponseObjectParticipantNotificationLevel { + int get apiValue => + _$GetRoomResponseObjectParticipantNotificationLevelEnumMap[this]!; +} + enum GetRoomResponseObjectMessageActorType { @JsonValue('deleted_users') deletedUsers, diff --git a/lib/api/marianumcloud/talk/room/notification_level.dart b/lib/api/marianumcloud/talk/room/notification_level.dart new file mode 100644 index 0000000..39f1d6a --- /dev/null +++ b/lib/api/marianumcloud/talk/room/notification_level.dart @@ -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; +} diff --git a/lib/api/marianumcloud/talk/share_files_to_chat.dart b/lib/api/marianumcloud/talk/share_files_to_chat.dart index 9be0faa..5a7facb 100644 --- a/lib/api/marianumcloud/talk/share_files_to_chat.dart +++ b/lib/api/marianumcloud/talk/share_files_to_chat.dart @@ -17,6 +17,10 @@ Future? _shareFolderReady; Future ensureTalkShareFolder() => _shareFolderReady ??= _createTalkShareFolder(); +/// The folder is per user; after a sign-out the next account has to check +/// again. +void resetTalkShareFolderCache() => _shareFolderReady = null; + Future _createTalkShareFolder() async { try { final webdav = await WebdavApi.webdav; diff --git a/lib/api/marianumcloud/talk/talk_api.dart b/lib/api/marianumcloud/talk/talk_api.dart index f8c3e60..64e7e29 100644 --- a/lib/api/marianumcloud/talk/talk_api.dart +++ b/lib/api/marianumcloud/talk/talk_api.dart @@ -12,6 +12,10 @@ import '../nextcloud_ocs.dart'; import 'talk_error.dart'; abstract class TalkApi { + // 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; ApiParams? body; Map? headers; @@ -26,6 +30,10 @@ abstract class TalkApi { ); 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 assembleAsync(String raw) async => assemble(raw); + Future run() async { final endpoint = NextcloudOcs.uri( 'apps/spreed/api/$path', @@ -35,7 +43,7 @@ abstract class TalkApi { final data = await sendGuarded( 'Talk $endpoint', - () => request(endpoint, body, mergedHeaders), + () => request(endpoint, body, mergedHeaders)?.timeout(_timeout), ); if (data == null) { throw const NetworkException( @@ -65,7 +73,7 @@ abstract class TalkApi { } try { - final assembled = assemble(data.body); + final assembled = await assembleAsync(data.body); assembled?.headers = data.headers; return assembled; } catch (e) { diff --git a/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart index b674b10..dcee7b9 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart @@ -29,6 +29,19 @@ class CacheableFile { /// file/folder in the list. Nullable so older cached entries decode fine. 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({ required this.path, required this.isDirectory, diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart index a6e8178..feba2a7 100644 --- a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart @@ -1,9 +1,9 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; -import 'package:localstore/localstore.dart'; import '../../../../../utils/cache_invalidation_bus.dart'; +import '../../../../cache_store.dart'; import '../../../../request_cache.dart'; import 'list_files.dart'; import 'list_files_params.dart'; @@ -43,14 +43,11 @@ class ListFilesCache extends SimpleCache { /// (slow) root listing is already populated by the time the user /// 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. static Future prefetchRootListing() async { const rootPath = ''; - final cached = await Localstore.instance - .collection(RequestCache.collection) - .doc(_documentId(rootPath)) - .get(); + final cached = await CacheStore.instance.read(_documentId(rootPath)); if (cached != null) return; // 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. @@ -69,10 +66,7 @@ class ListFilesCache extends SimpleCache { /// `_FilesView` for that path via [CacheInvalidationBus] so it refetches /// even while it is sitting in the background of the navigation stack. static Future invalidate(String path) async { - await Localstore.instance - .collection(RequestCache.collection) - .doc(_documentId(path)) - .delete(); + await CacheStore.instance.delete(_documentId(path)); CacheInvalidationBus.notifyListFiles(path); } } diff --git a/lib/api/marianumcloud/webdav/webdav_api.dart b/lib/api/marianumcloud/webdav/webdav_api.dart index 306d945..34fe90b 100644 --- a/lib/api/marianumcloud/webdav/webdav_api.dart +++ b/lib/api/marianumcloud/webdav/webdav_api.dart @@ -18,6 +18,8 @@ abstract class WebdavApi { /// changes (app password minted/renewed, account switch) so it never keeps /// authenticating with stale credentials. static Future 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 secret = '${nextcloud.username}:${nextcloud.secret}'; if (_webdav == null || _webdavSecret != secret) { diff --git a/lib/api/marianumconnect/auth/auth_interceptor.dart b/lib/api/marianumconnect/auth/auth_interceptor.dart index 75fdbb9..5b85d47 100644 --- a/lib/api/marianumconnect/auth/auth_interceptor.dart +++ b/lib/api/marianumconnect/auth/auth_interceptor.dart @@ -105,17 +105,29 @@ class MarianumConnectAuthInterceptor extends Interceptor { Future _performReLogin() async { final session = SessionManager().current; 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 { await _loginClient.run( - username: session.username, + username: username, password: session.password, tokenName: await DeviceTokenName.resolve(), ); - return true; } catch (_) { - await _tokenStorage.clear(); + if (await SessionManager().readStoredUsername() == username) { + await _tokenStorage.clear(); + } 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> _retryWithFreshToken( diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart index 5b3b1ec..7cb7be1 100644 --- a/lib/api/marianumconnect/auth/session_validator.dart +++ b/lib/api/marianumconnect/auth/session_validator.dart @@ -19,6 +19,7 @@ class SessionValidator { // 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. if (session == null || session.isDemo) return; + final epoch = SessionManager().sessionEpoch; try { switch (session) { case CredentialSession(:final username, :final password): @@ -28,6 +29,9 @@ class SessionValidator { } } on AuthException catch (e) { 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'); final next = await SessionLifecycle.signOut( notice: switch (session) { diff --git a/lib/api/marianumconnect/marianumconnect_api.dart b/lib/api/marianumconnect/marianumconnect_api.dart index 5030ea0..ad38c3c 100644 --- a/lib/api/marianumconnect/marianumconnect_api.dart +++ b/lib/api/marianumconnect/marianumconnect_api.dart @@ -16,6 +16,16 @@ class MarianumConnectApi { 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 /// the auth queries (login/verify) that must bypass the bearer/demo /// interceptors to avoid a re-auth loop. diff --git a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart index 8a8b2a5..40a7a60 100644 --- a/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart +++ b/lib/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart @@ -3,41 +3,71 @@ import 'dart:convert'; import 'dart:io'; 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 '../../../../push/push_registration.dart'; import '../../../../push/push_registration_store.dart'; import '../../../../push/push_registration_type.dart'; import '../../marianumconnect_query.dart'; import 'telemetry_device_id.dart'; +typedef _PushState = ({ + bool enabled, + AuthorizationStatus? permission, + String? pushDeviceIdentifier, +}); + /// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) — /// upserts the stable install id, platform, app version and device info. Sent -/// on app start, again once push registration completes that session (so a -/// fresh registration isn't under-reported until the next launch), and on -/// resume after the app spent more than 15 minutes in the background. +/// on app start, on resume and once push registration completes, throttled to +/// one per 15 minutes unless the push state changed in between. /// Bearer-authenticated via the shared dio interceptor. Replaces the legacy /// mhsl.eu `server/userIndex/update` call. class TelemetryHeartbeat extends MarianumConnectQuery { 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 - /// send never disrupts app start. Used from the app shell's initState and - /// lifecycle handler, and re-emitted once push registration completes (see - /// `_MainState._syncPush`). + /// send never disrupts app start. A changed push state (toggle, OS + /// permission — e.g. back from the system settings — or a fresh + /// 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}) { - unawaited( - TelemetryHeartbeat() - .send(notificationsEnabled: notificationsEnabled) - .catchError((Object _) {}), - ); + unawaited(_report(notificationsEnabled).catchError((Object _) {})); } - Future send({required bool notificationsEnabled}) => guard(() async { + static Future _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 _send(_PushState push) => guard(() async { final info = DeviceInfoPlugin(); final package = await PackageInfo.fromPlatform(); final deviceIdentifier = await TelemetryDeviceId.resolve(); - final pushDeviceIdentifier = await const PushRegistrationStore() - .deviceIdentifier(PushRegistrationType.general); var platform = 'unknown'; String? deviceModel; @@ -62,11 +92,12 @@ class TelemetryHeartbeat extends MarianumConnectQuery { data: { 'deviceIdentifier': deviceIdentifier, // `pushDeviceIdentifier` reflects a *completed* registration and is - // absent until it lands; `pushEnabled` carries the user's intent - // (the notification toggle) so the backend can tell "user wants push" - // apart from "registration not finished yet". - 'pushDeviceIdentifier': ?pushDeviceIdentifier, - 'pushEnabled': notificationsEnabled, + // absent until it lands; `pushEnabled` carries the user's intent (the + // toggle). The server combines it with `osPermission` into the + // visible-vs-silent delivery decision. + 'pushDeviceIdentifier': ?push.pushDeviceIdentifier, + 'pushEnabled': push.enabled, + 'osPermission': ?osPermissionWireValue(push.permission), 'platform': platform, 'appVersion': package.version, '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, +}; diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart index 52a94cc..add8228 100644 --- a/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart +++ b/lib/api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart @@ -1,6 +1,6 @@ import 'dart:developer'; -import 'package:localstore/localstore.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../../../session/session.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 /// duplicated. The flag is set only once MHSL reports no remaining events. class CustomEventsMigration { - static const String _collection = 'MarianumMobile'; - static const String _document = 'customEventsMigration'; - static const String _doneKey = 'migratedToMc'; + static const String _doneKey = 'customEventsMigratedToMc'; const CustomEventsMigration._(); @@ -34,18 +32,23 @@ class CustomEventsMigration { if (session is! CredentialSession || session.isDemo) return; if (await _isDone()) return; + final epoch = SessionManager().sessionEpoch; try { final response = await GetCustomTimetableEvent( GetCustomTimetableEventParams(session.legacyUserSecret), ).run(); 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 RemoveCustomTimetableEvent( RemoveCustomTimetableEventParams(event.id), ).run(); } + if (!SessionManager().isCurrentSession(epoch)) return; await _markDone(); log( 'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.', @@ -57,17 +60,11 @@ class CustomEventsMigration { } } - static Future _isDone() async { - final data = await Localstore.instance - .collection(_collection) - .doc(_document) - .get(); - return data != null && data[_doneKey] == true; - } + // SharedPreferences, like the old cache document, is cleared on sign-out, + // so the next account runs its own migration. + static Future _isDone() async => + (await SharedPreferences.getInstance()).getBool(_doneKey) ?? false; - static Future _markDone() async { - await Localstore.instance.collection(_collection).doc(_document).set({ - _doneKey: true, - }); - } + static Future _markDone() async => + (await SharedPreferences.getInstance()).setBool(_doneKey, true); } diff --git a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart index 48bcc7e..50de381 100644 --- a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart +++ b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart @@ -10,12 +10,17 @@ class McTimetableTeacher { final String displayName; final String? originalShortName; final String? originalDisplayName; + // Webuntis element ids; null from servers that predate them. + final int? id; + final int? originalId; McTimetableTeacher({ required this.shortName, required this.displayName, this.originalShortName, this.originalDisplayName, + this.id, + this.originalId, }); factory McTimetableTeacher.fromJson(Map json) => @@ -45,6 +50,8 @@ class McTimetableEntry { final String? substitutionText; final String? lessonText; final String? infoText; + // Index-aligned with [classNames]; null from servers that predate it. + final List? classIds; McTimetableEntry({ required this.id, @@ -60,6 +67,7 @@ class McTimetableEntry { required this.substitutionText, required this.lessonText, required this.infoText, + this.classIds, }); factory McTimetableEntry.fromJson(Map json) => @@ -68,10 +76,17 @@ class McTimetableEntry { /// Combines the calendar date with the hour/minute portion of [startTime] /// (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 get endDateTime => + late final DateTime _endDateTime = DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute); static DateTime _dateFromJson(String raw) => DateTime.parse(raw); diff --git a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.g.dart b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.g.dart index 96edde4..d32bd40 100644 --- a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.g.dart +++ b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.g.dart @@ -12,6 +12,8 @@ McTimetableTeacher _$McTimetableTeacherFromJson(Map json) => displayName: json['displayName'] as String, originalShortName: json['originalShortName'] as String?, originalDisplayName: json['originalDisplayName'] as String?, + id: (json['id'] as num?)?.toInt(), + originalId: (json['originalId'] as num?)?.toInt(), ); Map _$McTimetableTeacherToJson(McTimetableTeacher instance) => @@ -20,6 +22,8 @@ Map _$McTimetableTeacherToJson(McTimetableTeacher instance) => 'displayName': instance.displayName, 'originalShortName': instance.originalShortName, 'originalDisplayName': instance.originalDisplayName, + 'id': instance.id, + 'originalId': instance.originalId, }; McTimetableEntry _$McTimetableEntryFromJson(Map json) => @@ -43,6 +47,9 @@ McTimetableEntry _$McTimetableEntryFromJson(Map json) => substitutionText: json['substitutionText'] as String?, lessonText: json['lessonText'] as String?, infoText: json['infoText'] as String?, + classIds: (json['classIds'] as List?) + ?.map((e) => (e as num).toInt()) + .toList(), ); Map _$McTimetableEntryToJson(McTimetableEntry instance) => @@ -60,6 +67,7 @@ Map _$McTimetableEntryToJson(McTimetableEntry instance) => 'substitutionText': instance.substitutionText, 'lessonText': instance.lessonText, 'infoText': instance.infoText, + 'classIds': instance.classIds, }; TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson( diff --git a/lib/api/request_cache.dart b/lib/api/request_cache.dart index 86d705b..c797c2e 100644 --- a/lib/api/request_cache.dart +++ b/lib/api/request_cache.dart @@ -1,10 +1,11 @@ import 'dart:async'; import 'dart:convert'; -import 'package:localstore/localstore.dart'; - +import '../session/session_manager.dart'; import 'api_response.dart'; +import 'cache_store.dart'; import 'errors/parse_exception.dart'; +import 'errors/stale_session_exception.dart'; abstract class RequestCache { static const int cacheNothing = 0; @@ -12,12 +13,10 @@ abstract class RequestCache { static const int cacheHour = 60 * 60; static const int cacheDay = 60 * 60 * 24; - static String collection = 'MarianumMobile'; - int maxCacheTime; 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 /// cache hits from authoritative network responses. void Function(T)? onCacheData; @@ -48,42 +47,57 @@ abstract class RequestCache { static void ignore(Exception e) {} Future start(String document) async { + final epoch = SessionManager().sessionEpoch; try { - final tableData = await Localstore.instance - .collection(collection) - .doc(document) - .get(); - if (tableData != null) { - final cached = onLocalData(tableData['json'] as String); + final entry = await CacheStore.instance.read(document); + var lastUpdate = entry?.lastUpdate ?? 0; + T? cached; + if (entry != null) { + try { + 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); onCacheData?.call(cached); } - - 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); - } + await _load(document, epoch, lastUpdate: lastUpdate); } finally { if (!_ready.isCompleted) _ready.complete(); } } - T onLocalData(String json); + Future _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 json); Future onLoad(); } @@ -117,8 +131,7 @@ class SimpleCache extends RequestCache { Future onLoad() => _loader(); @override - T onLocalData(String json) => - _fromJson(jsonDecode(json) as Map); + T fromCacheJson(Map json) => _fromJson(json); } /// Captures the latest cache payload (cached or network) and rethrows the @@ -141,8 +154,10 @@ Future resolveFromCache( onError?.call(e); }); await cache.ready; - if (latest != null) return latest as T; 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; throw ParseException( technicalDetails: operationName != null diff --git a/lib/app.dart b/lib/app.dart index d31b2c6..89f059d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'main.dart'; @@ -45,18 +46,15 @@ class App extends StatefulWidget { } class _AppState extends State with WidgetsBindingObserver { - late Timer _updateTimings; StreamSubscription? _onMessageSub; StreamSubscription? _onMessageOpenedAppSub; StreamSubscription? _fcmTokenRefreshSub; int _knownTotalTabs = 1; int _lastTabIndex = 0; bool _userOnLastTab = false; - DateTime? _lastTelemetryAt; static const Duration _chatListActiveInterval = Duration(seconds: 15); static const Duration _chatListIdleInterval = Duration(seconds: 60); - static const Duration _telemetryInterval = Duration(minutes: 15); void _onTabControllerChanged() { final newIndex = Main.bottomNavigator.index; @@ -78,18 +76,15 @@ class _AppState extends State with WidgetsBindingObserver { bloc.setAutoRefreshInterval( 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() { if (!mounted) return; - final now = DateTime.now(); - final last = _lastTelemetryAt; - if (last != null && now.difference(last) < _telemetryInterval) return; - _lastTelemetryAt = now; TelemetryHeartbeat.report( notificationsEnabled: context .read() @@ -103,6 +98,7 @@ class _AppState extends State with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { log('AppLifecycle: $state'); if (state == AppLifecycleState.resumed) { + MarianumConnectApi.resetConnections(); _reportTelemetry(); Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () { if (!mounted) return; @@ -113,6 +109,7 @@ class _AppState extends State with WidgetsBindingObserver { _syncChatListPolling(refresh: false); _handlePendingWidgetNavigation(); } else if (mounted) { + context.read().flushSilentSave(); // Stop polling while backgrounded: a silent refresh failing in the // background would otherwise leave an error that flashes on the next // resume before the foreground refetch replaces it. @@ -162,7 +159,7 @@ class _AppState extends State with WidgetsBindingObserver { void _handlePendingShare() { if (!mounted) return; 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 // on top with stale (already-cleared) file paths. // Sharing targets Talk chats and Files folders only. @@ -181,6 +178,7 @@ class _AppState extends State with WidgetsBindingObserver { if (navigator.canPop()) { navigator.popUntil((route) => route.isFirst || route is PopupRoute); } + ShareIntentListener.instance.markShown(share); AppRoutes.openShareTarget(context, share); } @@ -198,19 +196,18 @@ class _AppState extends State with WidgetsBindingObserver { context.read().refresh(); context.read().refresh(); // Initial publish in case hydrated storage already has data. No refresh - // needed: PrimaryTimetableScope hands out a freshly loading bloc. - _publishWidget(context.read()); + // needed: PrimaryTimetableScope hands out a freshly loading bloc. The + // 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()); + }); unawaited(_handlePendingWidgetNavigation()); - ShareIntentListener.instance.attach(); ShareIntentListener.pending.addListener(_handlePendingShare); _handlePendingShare(); _syncChatListPolling(); }); - _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { - if (mounted) setState(() {}); - }); - _reportTelemetry(); // A refreshed FCM token invalidates the existing push subscription — the @@ -253,18 +250,20 @@ class _AppState extends State with WidgetsBindingObserver { NotificationController.onAppOpenedByNotification(message, context); }); - DataCleaner.cleanOldCache(); + // Housekeeping only; kept out of the cold-start window. + Future.delayed( + const Duration(seconds: 20), + DataCleaner.cleanOldCache, + ); } @override void dispose() { - _updateTimings.cancel(); _onMessageSub?.cancel(); _onMessageOpenedAppSub?.cancel(); _fcmTokenRefreshSub?.cancel(); PushTapRouter.pendingTarget.removeListener(_onPushTargetPending); ShareIntentListener.pending.removeListener(_handlePendingShare); - ShareIntentListener.instance.detach(); Main.bottomNavigator.removeListener(_onTabControllerChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); @@ -280,7 +279,7 @@ class _AppState extends State with WidgetsBindingObserver { // one widget reload for the burst is enough. listener: (_, _) => Debouncer.debounce( 'widgetPublish', - const Duration(milliseconds: 500), + const Duration(seconds: 1), () { if (mounted) _publishWidget(context.read()); }, @@ -290,7 +289,18 @@ class _AppState extends State with WidgetsBindingObserver { Widget _buildShell( BuildContext context, - ) => BlocBuilder( + ) => BlocSelector( + // 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, _) { final bottomBarModules = AppModule.getBottomBarModules(context); final totalTabs = bottomBarModules.length + 1; diff --git a/lib/extensions/date_time.dart b/lib/extensions/date_time.dart index 485b275..43fe685 100644 --- a/lib/extensions/date_time.dart +++ b/lib/extensions/date_time.dart @@ -36,6 +36,11 @@ extension CalendarDayArithmetic on DateTime { ); 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 diff --git a/lib/extensions/text.dart b/lib/extensions/text.dart index 879e7ec..4459d82 100644 --- a/lib/extensions/text.dart +++ b/lib/extensions/text.dart @@ -1,16 +1,29 @@ import 'package:flutter/material.dart'; extension TextExt on Text { - Size get size { - final textPainter = TextPainter( + /// Single-line width as rendered in [context] (honours the text scaler). + /// 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), maxLines: 1, textDirection: TextDirection.ltr, - )..layout(minWidth: 0, maxWidth: double.infinity); - return textPainter.size; + textScaler: scaler, + )..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. String firstNonEmpty(List values) { for (final v in values) { diff --git a/lib/main.dart b/lib/main.dart index 99509a2..31cd9c3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart' show timeDilation; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.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:path_provider/path_provider.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/marianumconnect/auth/session_validator.dart'; import 'api/marianumconnect/marianumconnect_endpoint.dart'; @@ -26,6 +27,7 @@ import 'app.dart'; import 'auth_link/guardian_link_listener.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; +import 'model/session_wipe.dart'; import 'notification/notification_service.dart'; import 'push/notification_permission_prompt.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/bloc/account_bloc.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/chat_list/bloc/chat_list_bloc.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 'widget/avatar_disk_cache.dart'; import 'widget/breaker/breaker.dart'; +import 'widget/debug/debug_stats_overlay.dart'; import 'widget/downloads/download_tray.dart'; import 'widget/emergency/emergency_notice_gate.dart'; import 'widget/info_dialog.dart'; @@ -120,6 +124,7 @@ void _installErrorHandlers() { Future main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); + SessionManager().markUiEngine(); // Before any initialisation so startup failures reach the backend too. _installErrorHandlers(); @@ -179,6 +184,12 @@ Future main() async { ); 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 // Android channels, then register the FCM background isolate handler that // decrypts and renders Nextcloud pushes while the app is not in foreground. @@ -198,9 +209,9 @@ Future main() async { ), ); - // Wire up the home-screen widget bridge before runApp so any widget render - // triggered during startup hits initialised native storage. - await _startupStep('widget sync', WidgetSync.ensureInitialized); + // The home-screen widget bridge must be ready before runApp so any widget + // render triggered during startup hits initialised native storage. + await widgetSyncInit; unawaited( WidgetBackgroundTask.initialize().onError( (e, _) => log('Workmanager init failed: $e'), @@ -297,13 +308,19 @@ class Main extends StatefulWidget { } class _MainState extends State
{ + final List _navigatorObservers = [ + AppRoutes.chatRouteObserver, + DownloadRouteObserver(), + ]; bool _showPostLoginSplash = false; bool _appMounted = true; + late AccountStatus _lastStatus; @override void initState() { super.initState(); Jiffy.setLocale('de'); + _lastStatus = context.read().state.status; SessionManager().unauthorizedSignal.addListener(_onUnauthorized); GuardianLinkListener.pending.addListener(_onGuardianLink); @@ -332,8 +349,10 @@ class _MainState extends State
{ final capabilitiesCubit = scopeContext.read(); unawaited( capabilitiesCubit.load().then((_) { - if (!mounted) return; + // Closed: the account was switched away from while loading. + if (!mounted || capabilitiesCubit.isClosed) return; _syncPush(settingsCubit, capabilitiesCubit); + _applyRoleDefaults(settingsCubit, capabilitiesCubit); _promptGuardianNotifications(); }), ); @@ -393,6 +412,19 @@ class _MainState extends State
{ ); } + /// 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 void dispose() { SessionManager().unauthorizedSignal.removeListener(_onUnauthorized); @@ -444,55 +476,72 @@ class _MainState extends State
{ if (mounted) setState(() => _appMounted = true); }); } - if (accountState.status == AccountStatus.addingAccount) { - // Routes pushed on the root navigator (Settings) would otherwise keep - // covering the login screen that replaces the home route. - AppRoutes.rootNavigatorKey.currentState?.popUntil( - (route) => route.isFirst, - ); - } - 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(); + final wasLoggedIn = _lastStatus == AccountStatus.loggedIn; + _lastStatus = accountState.status; + // A cold start that merely resolves as signed out has nothing to wipe, + // and a share waiting for the login must survive it. + if (accountState.status != AccountStatus.loggedOut || !wasLoggedIn) return; // Deferred until the account-scoped tree is torn down. 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 Widget build(BuildContext context) => Directionality( textDirection: TextDirection.ltr, - child: BlocBuilder( - builder: (context, settings) { + // Selects only what the root uses: Settings is mutated in place and + // re-emitted as a fresh instance on every write, so a plain BlocBuilder + // would rebuild MaterialApp (and every route) for unrelated settings. + child: BlocSelector( + 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 // background isolate and the iOS NSE can suppress rendering when off. unawaited( const PushRegistrationStore().setNotificationsEnabled( - settings.notificationSettings.enabled, + root.notificationsEnabled, ), ); + timeDilation = root.slowAnimations ? 5.0 : 1.0; return BlocConsumer( listenWhen: (previous, current) => previous.status != current.status || previous.accountId != current.accountId, listener: _onAccountChanged, buildWhen: (previous, current) => - previous.accountId != current.accountId, + _scopeOf(previous) != _scopeOf(current), builder: (context, account) { - if (account.accountId != _scopeAccountId) { - _scopeAccountId = account.accountId; + final scope = _scopeOf(account); + if (scope != _scope) { + _scope = scope; // The old navigator (and every route on it) goes with the old // account; a shared GlobalKey would carry it over instead. AppRoutes.rootNavigatorKey = GlobalKey(); } return AccountScope( - key: ValueKey(account.accountId), + key: ValueKey(scope), onActive: _onSessionActive, - child: _buildApp(settings), + child: _buildApp(root), ); }, ); @@ -500,101 +549,97 @@ class _MainState extends State
{ ), ); - Widget _buildApp(Settings settings) { - final devToolsSettings = settings.devToolsSettings; - return MaterialApp( - showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, - checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers, - checkerboardRasterCacheImages: - devToolsSettings.checkerboardRasterCacheImages, - debugShowCheckedModeBanner: false, - navigatorKey: AppRoutes.rootNavigatorKey, - // Used by ChatView.didPopNext to reclaim the global ChatBloc. - // DownloadRouteObserver tracks full-page navigations so the downloads - // chip only surfaces once the user leaves the screen they started on. - navigatorObservers: [ - AppRoutes.chatRouteObserver, - DownloadRouteObserver(), - ], - localizationsDelegates: const [ - ...GlobalMaterialLocalizations.delegates, - GlobalWidgetsLocalizations.delegate, - ], - supportedLocales: const [Locale('de'), Locale('en')], - locale: const Locale('de'), - title: 'Marianum Fulda', - themeMode: settings.appTheme, - theme: LightAppTheme.theme, - darkTheme: DarkAppTheme.theme, - // 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( + Widget _buildApp(_RootSettings root) => MaterialApp( + showPerformanceOverlay: root.showPerformanceOverlay, + showSemanticsDebugger: root.showSemanticsDebugger, + debugShowCheckedModeBanner: false, + navigatorKey: AppRoutes.rootNavigatorKey, + // Used by ChatView.didPopNext to reclaim the global ChatBloc. + // DownloadRouteObserver tracks full-page navigations so the downloads + // chip only surfaces once the user leaves the screen they started on. + navigatorObservers: _navigatorObservers, + localizationsDelegates: const [ + ...GlobalMaterialLocalizations.delegates, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: const [Locale('de'), Locale('en')], + locale: const Locale('de'), + title: 'Marianum Fulda', + themeMode: root.appTheme, + theme: LightAppTheme.theme, + darkTheme: DarkAppTheme.theme, + // 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) { + Widget app = ColoredBox( color: LightAppTheme.marianumRed, - // Downloads tray mounted ABOVE the navigator so its chip floats over - // every route (folder views, chat, viewer are full-page pushes that - // would otherwise cover it). + // Downloads tray mounted ABOVE the navigator so its chip floats + // over every route (folder views, chat, viewer are full-page + // pushes that would otherwise cover it). child: DownloadTrayHost(child: child ?? const SizedBox.shrink()), - ), - home: EmergencyNoticeGate( - child: LoaderOverlay( - child: Breaker( - breaker: BreakerArea.global, - child: BlocBuilder( - buildWhen: (previous, current) => - previous.status != current.status, - builder: (context, accountState) { - switch (accountState.status) { - case AccountStatus.loggedIn: - return Stack( - fit: StackFit.expand, - children: [ - if (_appMounted) const App(key: ValueKey('app-shell')), - if (_showPostLoginSplash) - PostLoginSplash( - 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(); - } - }, - ), + ); + final scale = root.textScaleOverride; + if (scale > 0) { + app = MediaQuery.withClampedTextScaling( + minScaleFactor: scale, + maxScaleFactor: scale, + child: app, + ); + } + return DebugStatsOverlay( + showFrameStats: root.showFrameStats, + showMemoryStats: root.showMemoryStats, + child: app, + ); + }, + home: EmergencyNoticeGate( + child: LoaderOverlay( + child: Breaker( + breaker: BreakerArea.global, + child: BlocBuilder( + buildWhen: (previous, current) => previous.status != current.status, + builder: (context, accountState) { + switch (accountState.status) { + case AccountStatus.loggedIn: + return Stack( + fit: StackFit.expand, + children: [ + if (_appMounted) const App(key: ValueKey('app-shell')), + if (_showPostLoginSplash) + PostLoginSplash( + 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 -/// itself is removed by [SessionLifecycle.signOut]. -Future _wipeDeviceState() async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.clear(); - // The chat background image lives outside HydratedStorage, so clear it too - // (best-effort) to avoid orphaning the previous user's wallpaper. - final backgroundImage = File(AppPaths.chatBackgroundImage); - 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); - } -} +typedef _RootSettings = ({ + ThemeMode appTheme, + bool notificationsEnabled, + bool showPerformanceOverlay, + bool showSemanticsDebugger, + bool showFrameStats, + bool showMemoryStats, + bool slowAnimations, + double textScaleOverride, +}); diff --git a/lib/model/data_cleaner.dart b/lib/model/data_cleaner.dart index 925980c..d24c197 100644 --- a/lib/model/data_cleaner.dart +++ b/lib/model/data_cleaner.dart @@ -1,24 +1,8 @@ -import 'package:localstore/localstore.dart'; - -import '../api/request_cache.dart'; +import '../api/cache_store.dart'; class DataCleaner { static Future cleanOldCache() async { - final cacheData = await Localstore.instance - .collection(RequestCache.collection) - .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(); - } - }); + await CacheStore.deleteLegacyLocalstoreCache(); + await CacheStore.instance.deleteOlderThan(const Duration(days: 200)); } } diff --git a/lib/model/session_wipe.dart b/lib/model/session_wipe.dart new file mode 100644 index 0000000..3bcbefc --- /dev/null +++ b/lib/model/session_wipe.dart @@ -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? _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 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 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 deviceLeft() { + final wipe = _deviceLeft(); + _running = wipe; + return wipe.whenComplete(() { + if (identical(_running, wipe)) _running = null; + }); + } + + static Future _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 _stepAsync( + String name, + Future Function() step, + ) async { + try { + await step(); + } catch (e, s) { + log('Session wipe step "$name" failed: $e', stackTrace: s); + } + } +} diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index e393883..dd35a00 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -7,6 +7,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../push/push_message_handler.dart'; import '../push/push_target.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/json_viewer.dart'; import '../widget/info_dialog.dart'; @@ -35,7 +37,14 @@ class NotificationController { ); await NotificationTasks.refreshBadge(); 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(); + Debouncer.debounce( + 'pushChatListRefresh', + const Duration(seconds: 1), + () => chatList.refresh(silent: true), + ); } static Future onAppOpenedByNotification( diff --git a/lib/notification/notification_service.dart b/lib/notification/notification_service.dart index dd5819f..bdd4953 100644 --- a/lib/notification/notification_service.dart +++ b/lib/notification/notification_service.dart @@ -25,7 +25,12 @@ class NotificationService { // iOS Talk category mirrors the Android inline reply + mark-as-read actions // 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. + // The plugin defaults to prompting here, which would block startup on the + // OS dialog; the prompt belongs to maybePromptTalkNotifications instead. final iosSettings = DarwinInitializationSettings( + requestAlertPermission: false, + requestSoundPermission: false, + requestBadgePermission: false, notificationCategories: [ DarwinNotificationCategory( PushRenderer.iosTalkCategory, diff --git a/lib/push/push_actions.dart b/lib/push/push_actions.dart index a8c455a..6da0fe7 100644 --- a/lib/push/push_actions.dart +++ b/lib/push/push_actions.dart @@ -60,6 +60,10 @@ class PushActions { // auth header, the Talk POST never happens and the RemoteInput spinner // runs forever. 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( 'action=${response.actionId} payload=${response.payload} ' diff --git a/lib/push/push_decryptor.dart b/lib/push/push_decryptor.dart index 7f7785c..a2a49a6 100644 --- a/lib/push/push_decryptor.dart +++ b/lib/push/push_decryptor.dart @@ -1,7 +1,7 @@ import 'dart:convert'; -import 'dart:typed_data'; import 'package:crypton/crypton.dart'; +import 'package:flutter/foundation.dart'; import 'package:pointycastle/export.dart' as pc; import 'push_subject.dart'; @@ -25,6 +25,14 @@ class PushDecryptor { 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 /// encrypted subject. Returns true when no server key is configured (the /// 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)); +} diff --git a/lib/push/push_message_handler.dart b/lib/push/push_message_handler.dart index 1d64116..9ac324c 100644 --- a/lib/push/push_message_handler.dart +++ b/lib/push/push_message_handler.dart @@ -5,6 +5,7 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import '../background/widget_background_task.dart'; import '../notification/notification_service.dart'; +import '../session/session_manager.dart'; import 'chat_thread_store.dart'; import 'nid_store.dart'; import 'push_decryptor.dart'; @@ -54,6 +55,8 @@ PushKind classifyPush(Map data) { /// "must be annotated"), so a plain function is the reliable form. @pragma('vm:entry-point') Future pushOnBackgroundMessage(RemoteMessage message) async { + // This engine outlives sign-outs and logins in the app. + await SessionManager().reloadFromStorage(); await NotificationService().initializeNotifications(); await PushRenderer.ensureChannels(); await PushMessageHandler().handle(message); @@ -175,11 +178,15 @@ class PushMessageHandler { devicePrivateKey: privateKey, serverPublicKey: serverPublicKey, ); - if (!decryptor.verify(subjectBase64, signatureBase64)) { + final result = await decryptor.verifyAndDecryptInBackground( + subjectBase64, + signatureBase64, + ); + if (!result.verified) { log('Push: signature verification failed'); return; } - final subject = decryptor.decrypt(subjectBase64); + final subject = result.subject; if (subject == null) { log('Push: could not decrypt subject'); return; diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index 65714d5..902e39f 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -118,6 +118,7 @@ class PushRegistration { Future register() async { if (DemoMode.active) return false; if (_nextcloudOrNull == null) return _direct.register(); + final epoch = SessionManager().sessionEpoch; final String? fcmToken; try { fcmToken = await FirebaseMessaging.instance.getToken(); @@ -167,6 +168,7 @@ class PushRegistration { fcmToken: fcmToken, pems: pems, appVersion: appVersion, + epoch: epoch, ); allOk = allOk && ok; } @@ -178,6 +180,7 @@ class PushRegistration { required String fcmToken, required PushKeypairPems pems, required String? appVersion, + required int epoch, }) async { try { final proxyServer = currentProxyServer; @@ -193,6 +196,11 @@ class PushRegistration { 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( type: type, deviceIdentifier: registration.deviceIdentifier, @@ -355,9 +363,8 @@ class PushRegistration { status != AuthorizationStatus.deniedPermanently; /// Requests the OS notification permission (covers iOS + Android 13) and - /// returns whether registration should proceed. Errors from the plugin are - /// treated as usable — better a possibly-idle registration than silently - /// losing push over a transient failure. + /// returns whether it is usable (not explicitly denied). Errors from the + /// plugin are treated as usable so a transient failure never nags the user. static Future requestOsPermission() async { try { final settings = await FirebaseMessaging.instance.requestPermission(); @@ -368,56 +375,47 @@ class PushRegistration { } } - /// True when the user has explicitly denied the OS notification permission. - /// Read-only (no prompt) — used by the settings UI to surface the state. - static Future isOsPermissionDenied() async { + /// Current OS notification permission, or null when the plugin can't tell. + /// Read-only — never triggers the OS prompt. + static Future osPermissionStatus() async { try { final settings = await FirebaseMessaging.instance .getNotificationSettings(); - return !isPermissionUsable(settings.authorizationStatus); + return settings.authorizationStatus; } on Object { - return false; + return null; } } /// True when the OS notification permission is already granted /// (`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 isOsPermissionGranted() async { - try { - final settings = await FirebaseMessaging.instance - .getNotificationSettings(); - return settings.authorizationStatus == AuthorizationStatus.authorized || - settings.authorizationStatus == AuthorizationStatus.provisional; - } on Object { - return false; - } + final status = await osPermissionStatus(); + return status != null && canDisplay(status); } + /// 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. - /// Deliberately independent of the in-app notification toggle: a user who - /// turned notifications off stays registered so silent sync pushes keep - /// flowing — the display is suppressed downstream via the mirrored flag (see - /// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS - /// notification permission is *already* granted — it never triggers the OS - /// prompt itself. Requesting the permission is the job of the first Talk visit - /// (see `maybePromptTalkNotifications`), which keeps the prompt out of the - /// cold-start path. Safe to call on every start — Nextcloud dedups an - /// unchanged registration — which also self-heals a device whose registration - /// was lost. + /// Deliberately independent of both the in-app notification toggle and the + /// OS notification permission: silent sync pushes (deletes, chat/badge + /// refresh, widget refresh) need no permission — data-only FCM on Android, + /// `content-available` on iOS — so every device stays in sync. Whether a + /// visible notification is wanted is reported separately via the telemetry + /// heartbeat (toggle + OS permission), from which the server picks silent + /// pushes on iOS. Never triggers the OS prompt. Safe to call on every + /// start — Nextcloud dedups an unchanged registration — which also + /// self-heals a device whose registration was lost. /// Returns whether registration was actually *attempted* (all gates passed). /// Even a partial success persists the `general` device identifier, so the /// caller re-emits telemetry on `true` to reflect the fresh registration in /// the same session instead of lagging until the next launch. static Future syncSubscription({required bool capable}) async { if (!capable) return false; - if (!await isOsPermissionGranted()) { - log( - 'Push: OS notification permission not granted, skipping registration', - ); - return false; - } final registration = PushRegistration(); // register() below refreshes an unchanged subscription anyway; the check // only surfaces the endpoint switch in the log for diagnosability. diff --git a/lib/push/push_status.dart b/lib/push/push_status.dart index 3336080..3537d37 100644 --- a/lib/push/push_status.dart +++ b/lib/push/push_status.dart @@ -1,4 +1,3 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import '../session/session_manager.dart'; @@ -142,21 +141,11 @@ Future collectPushStatus({ } Future _osPermission() async { - try { - final settings = await FirebaseMessaging.instance.getNotificationSettings(); - switch (settings.authorizationStatus) { - case AuthorizationStatus.authorized: - case AuthorizationStatus.provisional: - return PushCheck.ok; - case AuthorizationStatus.denied: - case AuthorizationStatus.deniedPermanently: - return PushCheck.fail; - case AuthorizationStatus.notDetermined: - return PushCheck.unknown; - } - } on Object { - return PushCheck.unknown; - } + final status = await PushRegistration.osPermissionStatus(); + if (status == null) return PushCheck.unknown; + if (PushRegistration.canDisplay(status)) return PushCheck.ok; + if (!PushRegistration.isPermissionUsable(status)) return PushCheck.fail; + return PushCheck.unknown; } /// One line in the status checklist. diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index d236f61..0c53cb4 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -28,6 +28,7 @@ import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/share/qr_share_view.dart'; import '../view/pages/parent_letters/parent_letter_view.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/settings.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/subject_colors/subject_colors_view.dart'; import '../widget/avatar_crop_page.dart'; +import '../widget/debug/bloc_storage_view.dart'; import '../widget/debug/cache_view.dart'; +import '../widget/debug/json_viewer.dart'; import '../widget/file_viewer.dart'; import '../widget/large_profile_picture_view.dart'; import '../widget/user_avatar.dart'; @@ -294,8 +297,26 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const FeedbackDialog()); } - static void openCacheView(BuildContext context) { - pushScreen(context, withNavBar: false, screen: const CacheView()); + static Future openCacheView(BuildContext context) => + pushScreen(context, withNavBar: false, screen: const CacheView()); + + static Future 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) { diff --git a/lib/session/session_lifecycle.dart b/lib/session/session_lifecycle.dart index 75e57da..7376b89 100644 --- a/lib/session/session_lifecycle.dart +++ b/lib/session/session_lifecycle.dart @@ -10,8 +10,7 @@ import '../api/marianumconnect/queries/auth_logout/auth_logout.dart'; import '../api/marianumconnect/queries/auth_me/auth_me.dart'; import '../auth_link/guardian_link_listener.dart'; import '../background/widget_background_task.dart'; -import '../push/chat_thread_store.dart'; -import '../push/nid_store.dart'; +import '../model/session_wipe.dart'; import '../push/push_registration.dart'; import '../storage/account_storage.dart'; import '../widget_data/widget_sync.dart'; @@ -41,10 +40,11 @@ abstract final class SessionLifecycle { } await AuthLogout().run(); final removed = await SessionManager().signOut(); + SessionWipe.clearImmediate(); // A login link that arrived while signed in must not be replayed on the // login screen that follows. GuardianLinkListener.clear(); - await _clearPushStores(); + await SessionWipe.accountLeft(); if (removed != null) await AccountStorage.delete(removed.namespace); for ( @@ -78,14 +78,22 @@ abstract final class SessionLifecycle { log('Switch: push deactivation failed: $e'); } await SessionManager().stashActive(); + SessionWipe.clearImmediate(); + var restored = false; try { await SessionManager().activate(id); } on Object { - if (previous != null) await SessionManager().activate(previous.id); + if (previous != null) { + await SessionManager().activate(previous.id); + restored = true; + } rethrow; } finally { - await _clearPushStores(); + await SessionWipe.accountLeft(); 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(); } @@ -94,6 +102,10 @@ abstract final class SessionLifecycle { static Future beginAddAccount() async { await SessionManager().stashActive(); _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; @@ -109,6 +121,7 @@ abstract final class SessionLifecycle { await SessionManager().signOut(); } await SessionManager().activate(returnId); + await AccountStorage.activate(SessionManager().activeAccount); } /// 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'); } await SessionManager().activate(added.id); - await _clearPushStores(); + await SessionWipe.accountLeft(); } await AccountStorage.activate(SessionManager().activeAccount); return added.id; @@ -191,14 +204,4 @@ abstract final class SessionLifecycle { log('Widget reset failed: $e'); } } - - // Tray bookkeeping of the previous account's pushes. - static Future _clearPushStores() async { - try { - await NidStore().clear(); - await ChatThreadStore().clearAll(); - } on Object catch (e) { - log('Push store cleanup failed: $e'); - } - } } diff --git a/lib/session/session_manager.dart b/lib/session/session_manager.dart index ee4c17f..55e2648 100644 --- a/lib/session/session_manager.dart +++ b/lib/session/session_manager.dart @@ -58,6 +58,21 @@ class SessionManager { 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. final ValueNotifier accounts = ValueNotifier( AccountIndex.empty, @@ -115,6 +130,7 @@ class SessionManager { } Future _writeActive(Session session) async { + if (!_isSameAccount(_current, session)) _sessionEpoch++; await Future.wait([ for (final MapEntry(:key, :value) in encodeSessionFields(session).entries) _writeSecret(key, value), @@ -134,6 +150,7 @@ class SessionManager { /// stay in their vaults; see [activate]. Future signOut() async { final removed = activeAccount; + _sessionEpoch++; _loaded = Completer(); _current = null; await Future.wait([ @@ -193,6 +210,11 @@ class SessionManager { 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 _saveIndex(AccountIndex index) async { accounts.value = index; await _secureStorage.write(key: _indexKey, value: index.encode()); @@ -280,8 +302,15 @@ class SessionManager { Future _migrateAndLoad() async { await _migrateFromLegacyStorage(); await _migrateKeychainAccessibility(); - // On the startup critical path (and every background wake): read in - // parallel instead of one keychain round-trip after the other. + _current = await _readActive(); + 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 _readActive() async { final values = await Future.wait( _sessionFields.map((field) => _secureStorage.read(key: field)), ); @@ -296,9 +325,35 @@ class SessionManager { } on Object { // Group keystore unavailable: fall back to the real password. } - _current = decodeSession(raw); - await _loadIndex(); - if (!_loaded.isCompleted) _loaded.complete(); + return decodeSession(raw); + } + + /// Username currently in the active slots. Other engines (widget task, push + /// isolates) sign out, in or switch without this instance noticing. + Future 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 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 _loadIndex() async { diff --git a/lib/share_intent/pending_share.dart b/lib/share_intent/pending_share.dart index 9f8590e..6c875e2 100644 --- a/lib/share_intent/pending_share.dart +++ b/lib/share_intent/pending_share.dart @@ -5,11 +5,17 @@ class PendingShare { final String? text; 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 sourcePaths; + const PendingShare({ required this.filePaths, required this.text, required this.receivedAt, - }); + List? sourcePaths, + }) : sourcePaths = sourcePaths ?? filePaths; bool get hasFiles => filePaths.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 /// deliberately ignored here so such duplicates compare equal. bool contentEquals(PendingShare other) => - text == other.text && listEquals(filePaths, other.filePaths); + text == other.text && listEquals(sourcePaths, other.sourcePaths); } diff --git a/lib/share_intent/share_intent_listener.dart b/lib/share_intent/share_intent_listener.dart index eaa57ab..3398fd7 100644 --- a/lib/share_intent/share_intent_listener.dart +++ b/lib/share_intent/share_intent_listener.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:io'; 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 '../auth_link/guardian_login_link.dart'; @@ -15,55 +17,63 @@ class ShareIntentListener { static final ValueNotifier pending = ValueNotifier(null); - StreamSubscription>? _streamSub; - bool _initialized = false; + static const _androidChannel = MethodChannel('eu.mhsl.marianum.share'); - /// Reads the cold-start payload exactly once. Call from `main()` before - /// `runApp` so the share is queued before the UI mounts. + /// Per-share folders live below this directory name — on Android in the + /// 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 initialize() async { if (_initialized) return; _initialized = true; try { - final initial = await ReceiveSharingIntent.instance.getInitialMedia(); - final share = _toPendingShare(initial); - if (share != null) _publish(share); + if (Platform.isAndroid) { + _cacheDir = (await getTemporaryDirectory()).path; + _sweepStaleAndroidShares(); + } + ReceiveSharingIntent.instance.getMediaStream().listen( + _onItems, + onError: (Object e) => debugPrint('ShareIntentListener stream: $e'), + ); + _onItems(await ReceiveSharingIntent.instance.getInitialMedia()); await ReceiveSharingIntent.instance.reset(); + if (Platform.isAndroid) { + await _androidChannel.invokeMethod('listenerReady'); + } } catch (e) { debugPrint('ShareIntentListener.initialize failed: $e'); } } - /// Subscribes to warm-share stream events. Safe to call multiple times. - void attach() { - _streamSub ??= ReceiveSharingIntent.instance.getMediaStream().listen( - (items) { - final share = _toPendingShare(items); - if (share != null) _publish(share); - }, - onError: (Object e) => debugPrint('ShareIntentListener stream error: $e'), - ); + /// Whether the share flow for [share] is already on screen. Re-running the + /// navigation for it would pop its own page and thereby clear it. + bool isShown(PendingShare share) => identical(share, _shown); + + void markShown(PendingShare share) => _shown = share; + + /// Marks a share flow step that must not be interrupted (upload, share API + /// 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 - /// 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. + /// Discards the current share and deletes its temp files. Idempotent. /// /// Pass [ifCurrent] from UI that owns a specific share (e.g. the /// ShareTargetPage pop handler): the call then only acts while that share @@ -73,18 +83,58 @@ class ShareIntentListener { void clear({PendingShare? ifCurrent}) { final current = pending.value; if (ifCurrent != null && !identical(current, ifCurrent)) return; + _shown = null; pending.value = null; - if (current != null) { - for (final path in current.filePaths) { - try { - final f = File(path); - if (f.existsSync()) f.deleteSync(); - } catch (_) { - // best-effort cleanup; OS will reclaim cache eventually - } + if (current != null) _deleteFiles(current); + unawaited(ReceiveSharingIntent.instance.reset()); + if (_flowDepth == 0) _drainQueue(); + } + + /// Drops every share, including a queued one — for sign-out. + void clearAll() { + final queued = _queued; + _queued = null; + _flowDepth = 0; + if (queued != null) _deleteFiles(queued); + clear(); + } + + void _onItems(List 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 @@ -106,7 +156,9 @@ class ShareIntentListener { case SharedMediaType.image: case SharedMediaType.video: 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.url: if (!isAppLink(item.path)) texts.add(item.path); @@ -119,4 +171,77 @@ class ShareIntentListener { receivedAt: DateTime.now(), ); } + + /// The Android plugin copies shared content to `cacheDir/`, + /// 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 = []; + 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 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 + } + } } diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart index 9c07d3b..570c155 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -20,10 +20,15 @@ class LoadableStateBloc extends Bloc LoadableStateBloc() : super(const LoadableStateState(connections: null)) { on((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); - if (connectivityStatusKnown() && isConnected()) { - if (reFetch == null) return; - reFetch!(); + if ((wasOffline || event.fromResume) && + connectivityStatusKnown() && + isConnected()) { + reFetch?.call(); } }); @@ -54,7 +59,12 @@ class LoadableStateBloc extends Bloc unawaited( Connectivity().checkConnectivity().then((result) { if (isClosed) return; - add(ConnectivityChanged(LoadableStateState(connections: result))); + add( + ConnectivityChanged( + LoadableStateState(connections: result), + fromResume: true, + ), + ); }), ); } diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart index 40955b6..8924fb0 100644 --- a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart @@ -4,5 +4,10 @@ sealed class LoadableStateEvent {} final class ConnectivityChanged extends LoadableStateEvent { 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}); } diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart index 7ddfa98..d18a7ae 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart @@ -69,7 +69,7 @@ class LoadableStateConsumer< // null mid-refetch, and toggling the RefreshIndicator on that signal would // rebuild the tree under the ListView and reset its scroll position. final content = SizedBox( - height: MediaQuery.of(context).size.height, + height: MediaQuery.sizeOf(context).height, child: hasContent ? child(typedData as TState, isLoading) : const SizedBox.shrink(), diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart index 417af24..df6eaef 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart @@ -95,10 +95,12 @@ class _LoadableStateErrorBarTextState extends State { late Timer _rebuildTimer; @override void initState() { - _rebuildTimer = Timer.periodic( - const Duration(seconds: 10), - (timer) => setState(() {}), - ); + // Only refresh the relative "last updated" text while this page is the + // visible one; offstage tabs and covered routes have tickers disabled. + _rebuildTimer = Timer.periodic(const Duration(seconds: 10), (timer) { + if (!mounted) return; + if (TickerMode.getValuesNotifier(context).value.enabled) setState(() {}); + }); super.initState(); } diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart index 2b70ead..23b14b2 100644 --- a/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart @@ -33,6 +33,9 @@ class _LoadableStatePrimaryLoadingState extends State { Timer? _slowHintTimer; 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 void initState() { @@ -44,6 +47,7 @@ class _LoadableStatePrimaryLoadingState void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) { super.didUpdateWidget(oldWidget); if (widget.visible != oldWidget.visible) _restartSlowHintTimer(); + if (widget.visible) _spinnerMounted = true; } void _restartSlowHintTimer() { @@ -62,43 +66,49 @@ class _LoadableStatePrimaryLoadingState } @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 = widget.statusText ?? (_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null); - - return AnimatedOpacity( - opacity: widget.visible ? 1.0 : 0.0, - duration: LoadableStateConsumer.animationDuration, - curve: Curves.easeInOut, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const AppProgressIndicator.large(), - AnimatedSwitcher( - duration: LoadableStateConsumer.animationDuration, - child: status == null - ? const SizedBox.shrink() - : Padding( - key: ValueKey(status), - padding: const EdgeInsets.only( - top: 16, - left: 24, - right: 24, - ), - child: Text( - status, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).hintColor, - ), + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppProgressIndicator.large(), + AnimatedSwitcher( + duration: LoadableStateConsumer.animationDuration, + child: status == null + ? const SizedBox.shrink() + : Padding( + key: ValueKey(status), + padding: const EdgeInsets.only( + top: 16, + left: 24, + right: 24, + ), + child: Text( + status, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).hintColor, ), ), - ), - ], - ), + ), + ), + ], ), ); } diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index 064f8c8..f62af00 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -1,10 +1,13 @@ +import 'dart:async'; import 'dart:developer'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../access/access_requirement.dart'; import '../../../../../api/errors/error_mapper.dart'; +import '../../../../../api/errors/stale_session_exception.dart'; import '../../../../../session/session_manager.dart'; +import '../../../../../utils/session_single_flight.dart'; import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loading_error.dart'; import '../../repository/repository.dart'; @@ -19,6 +22,12 @@ abstract class LoadableHydratedBloc< extends HydratedBloc, LoadableState> { 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() : super( const LoadableState( @@ -112,6 +121,8 @@ abstract class LoadableHydratedBloc< /// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user /// is authenticated again, otherwise the UI would stay blank. Future reset() async { + _lastPersistedKey = null; + _lastPersistedFetch = null; await clear(); add(Reset()); } @@ -123,6 +134,30 @@ abstract class LoadableHydratedBloc< 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 Function() body) => + runZoned(body, zoneValues: {_sessionKey: SessionManager().sessionEpoch}); + + @override + void add(LoadableHydratedBlocEvent 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; 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. /// Does not guard [isClosed] — callers decide whether a late error still /// applies. - void addLoadingError(Object e) => add( - Error( - LoadingError( - message: errorToUserMessage(e), - technicalDetails: errorToTechnicalDetails(e), - allowRetry: errorAllowsRetry(e), + void addLoadingError(Object e) { + // Belongs to a signed-out account; the current one loads on its own. + if (e is StaleSessionException) return; + add( + Error( + 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() { - if (!requirementsMet) return; - log('Fetching data for ${TState.toString()}'); - gatherData() - .catchError((Object e) { - log('Error while fetching ${TState.toString()}: ${e.toString()}'); - // The bloc may have been closed before this async error landed; - // adding to a closed bloc throws, so swallow that case. - if (isClosed) return; - addLoadingError(e); - }) - .then((value) { - log('Fetch for ${TState.toString()} completed!'); - }); + if (!requirementsMet || !ownsSession) return; + unawaited( + _fetchFlight.run(() { + log('Fetching data for ${TState.toString()}'); + return runInSession( + () => gatherData() + .catchError((Object e) { + log( + 'Error while fetching ${TState.toString()}: ${e.toString()}', + ); + // The bloc may have been closed before this async error + // 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 @@ -175,13 +227,27 @@ abstract class LoadableHydratedBloc< @override Map? toJson(LoadableState 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? data; try { - final stateData = state.data; data = stateData is TState ? toStorage(stateData) : null; } catch (e) { 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( 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 gatherData(); TRepository repository(); diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 3d0f34c..8764f1b 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -199,6 +199,27 @@ class AppModule { return order; } + /// Modules that are of no use to teachers and start out hidden for them. + static const Set 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 // indices refer to that subset; the move is applied there and merged back // into the full order (non-displayed modules keep their relative slots). diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart index 9240d2f..6a6d1c9 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart @@ -37,8 +37,12 @@ class CapabilitiesCubit extends HydratedCubit { ); return; } + final epoch = SessionManager().sessionEpoch; try { 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( CapabilitiesState( viewForeignTimetables: response.viewForeignTimetables, @@ -56,7 +60,7 @@ class CapabilitiesCubit extends HydratedCubit { } } - void reset() => emit(const CapabilitiesState()); + Future reset() async => emit(const CapabilitiesState()); @override CapabilitiesState fromJson(Map json) { diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index f8384b2..ebba68a 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -23,6 +23,9 @@ class ChatBloc final ChatListBloc? _chatListBloc; 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 _lastKnownMessageId = 0; bool _appResumed = true; @@ -56,6 +59,12 @@ class ChatBloc @override ChatState fromStorage(Map 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 Map? toStorage(ChatState state) { final response = state.chatResponse; @@ -123,6 +132,15 @@ class ChatBloc /// 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) /// 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 reset() { + _chatViewActive = false; + _stopLongPoll(); + return super.reset(); + } + void leaveChat(String fromToken) { if ((innerState?.currentToken ?? '') != fromToken) return; _chatViewActive = false; @@ -256,23 +274,25 @@ class ChatBloc _pollingToken = token; _backoffMs = 0; _lastKnownMessageId = _maxMessageId(innerState?.chatResponse); - unawaited(_pollLoop(token)); + unawaited(_pollLoop(token, ++_pollGeneration)); } void _stopLongPoll() { _pollingToken = null; + _pollGeneration++; _backoffMs = 0; } - Future _pollLoop(String token) async { - while (_pollingToken == token && !isClosed) { + Future _pollLoop(String token, int generation) async { + bool active() => generation == _pollGeneration && !isClosed; + while (active()) { try { final response = await LongPollChat( chatToken: token, lastKnownMessageId: _lastKnownMessageId, ).run(); - if (_pollingToken != token || isClosed) return; + if (!active()) return; _backoffMs = 0; if (response == null) continue; @@ -296,7 +316,7 @@ class ChatBloc _chatListBloc?.markRoomAsRead(token, _lastKnownMessageId); } } on Object catch (e) { - if (_pollingToken != token || isClosed) return; + if (!active()) return; log('LongPoll error for $token: $e'); _backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000); await Future.delayed(Duration(milliseconds: _backoffMs)); diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index 5187521..a4c16f3 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -1,11 +1,14 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:developer'; import 'package:flutter_app_badge/flutter_app_badge.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/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_event.dart'; import '../repository/chat_list_repository.dart'; @@ -31,6 +34,13 @@ class ChatListBloc return super.close(); } + // The timer outlives the app shell; the next shell re-arms it after login. + @override + Future reset() { + setAutoRefreshInterval(null); + return super.reset(); + } + /// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent. void setAutoRefreshInterval(Duration? interval) { if (interval == _autoRefreshInterval) return; @@ -72,14 +82,34 @@ class ChatListBloc renew: renew, onError: (e) => capturedError = e, ); + _lastRoomsJson = null; add(DataGathered((s) => s.copyWith(rooms: rooms))); _updateAppBadge(rooms); 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 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 refreshIfOlderThan(Duration maxAge) { + final lastFetch = state.lastFetch; + if (lastFetch != null && + DateTime.now().millisecondsSinceEpoch - lastFetch < + maxAge.inMilliseconds) { + return Future.value(); + } + return refresh(); + } + + Future _refresh({required bool renew, required bool silent}) async { if (!silent) add(RefetchStarted()); Object? capturedError; try { @@ -87,6 +117,11 @@ class ChatListBloc renew: renew, onError: (e) => capturedError = e, ); + if (silent) { + if (_isUnchanged(rooms)) return; + } else { + _lastRoomsJson = null; + } add(DataGathered((s) => s.copyWith(rooms: rooms))); _updateAppBadge(rooms); } catch (e) { @@ -95,6 +130,23 @@ class ChatListBloc 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 /// demo mode. Refreshes the list so the room shows up. Future createDirectChat(String invite) async { @@ -126,6 +178,18 @@ class ChatListBloc }); } + Future 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. void applyIncomingMessage(String token, GetChatResponseObject message) { _mutateRoom(token, (r) { @@ -158,6 +222,7 @@ class ChatListBloc }).toSet(); if (!changed) return; final newRooms = GetRoomResponse(updated)..headers = rooms.headers; + _lastRoomsJson = null; add(Emit((s) => s.copyWith(rooms: newRooms))); _updateAppBadge(newRooms); } diff --git a/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart index 63bcbbb..34793a6 100644 --- a/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart +++ b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:enough_icalendar/enough_icalendar.dart'; +import 'package:flutter/foundation.dart'; import '../bloc/marianum_dates_state.dart'; @@ -19,6 +20,12 @@ class MarianumDatesGetEvents { final body = response.data; 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 parseEvents(String body) { final root = VComponent.parse(body); final calendar = root is VCalendar ? root : null; final source = calendar?.children ?? root.children; diff --git a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart index 210da2c..af51c92 100644 --- a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart +++ b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart @@ -65,8 +65,12 @@ class NextcloudCapabilitiesCubit emit(DemoNextcloudCapabilities.state()); return; } + final epoch = SessionManager().sessionEpoch; try { 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( NextcloudCapabilitiesState( apiEnabled: caps.apiEnabled, @@ -92,7 +96,7 @@ class NextcloudCapabilitiesCubit } } - void reset() => emit(const NextcloudCapabilitiesState()); + Future reset() async => emit(const NextcloudCapabilitiesState()); @override NextcloudCapabilitiesState fromJson(Map json) { diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index bc56955..8c2aa4a 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:developer'; +import 'package:collection/collection.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import '../../../../../storage/settings.dart'; @@ -10,8 +11,15 @@ import '../../../../../view/pages/settings/data/default_settings.dart'; class SettingsCubit extends HydratedCubit { static const _debounceTag = 'settings_persist'; bool _emitScheduled = false; + Map? _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}) { if (write) { @@ -34,21 +42,53 @@ class SettingsCubit extends HydratedCubit { 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() { 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) { log('Failed to refresh settings state: $e'); } } Future reset() async { + _silentSave?.cancel(); + _silentSave = null; + _lastEmittedJson = null; emit(DefaultSettings.get()); } // Modules missing from a stale persisted moduleOrder are handled at read // time by AppModule.effectiveModuleOrder (inserted at their default // position) — no healing on hydration needed. + @override + Future close() { + flushSilentSave(); + return super.close(); + } + @override Settings fromJson(Map json) { try { diff --git a/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart b/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart index 48ae302..7c9d7a0 100644 --- a/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart +++ b/lib/state/app/modules/ticker/bloc/ticker_page_bloc.dart @@ -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/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; @@ -17,7 +22,35 @@ class TickerPageBloc > { 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 _rememberSlug() async { + try { + final prefs = await SharedPreferences.getInstance(); + final recent = prefs.getStringList(_recentSlugsKey) ?? []; + 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 String get id => slug; diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart index b6c3f74..50111f3 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -48,7 +48,7 @@ class TimetableBloc TimetableState fromNothing() { final reference = DateTime.now().addDays(2); return TimetableState( - startDate: _startOfWeek(reference), + startDate: reference.mondayOfWeek, endDate: _endOfWeek(reference), ); } @@ -61,7 +61,7 @@ class TimetableBloc final stored = TimetableState.fromJson(json); final reference = DateTime.now().addDays(2); return stored.copyWith( - startDate: _startOfWeek(reference), + startDate: reference.mondayOfWeek, endDate: _endOfWeek(reference), accessibleStartDate: null, accessibleEndDate: null, @@ -120,13 +120,15 @@ class TimetableBloc final current = innerState ?? fromNothing(); if (current.startDate == startDate && current.endDate == endDate) return; add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate))); - _loadCurrentWeek(startDate, endDate); - _prefetchAdjacentWeeks(startDate, endDate); + runInSession(() { + _loadCurrentWeek(startDate, endDate); + _prefetchAdjacentWeeks(startDate, endDate); + }); } void resetWeek() { final reference = DateTime.now().addDays(2); - changeWeek(_startOfWeek(reference), _endOfWeek(reference)); + changeWeek(reference.mondayOfWeek, _endOfWeek(reference)); } void refresh() => fetch(); @@ -171,11 +173,7 @@ class TimetableBloc Future _refreshSubjects() async { final subjects = await repo.data.getSubjects(renew: true); - add( - DataGathered( - (s) => s.copyWith(subjects: subjects, dataVersion: s.dataVersion + 1), - ), - ); + add(DataGathered((s) => s.withReferenceData(subjects: subjects))); } Future _loadCurrentWeek( @@ -214,12 +212,11 @@ class TimetableBloc add( Emit( - (s) => s.copyWith( + (s) => s.withReferenceData( rooms: rooms, subjects: subjects, schoolHolidays: schoolHolidays, schoolyear: schoolyear, - dataVersion: s.dataVersion + 1, ), ), ); @@ -229,11 +226,7 @@ class TimetableBloc try { final timegrid = await repo.data.getTimegrid(renew: renew); - add( - Emit( - (s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1), - ), - ); + add(Emit((s) => s.withReferenceData(timegrid: timegrid))); } catch (_) { // Timegrid load failure falls back to a hardcoded schedule in the UI layer. } @@ -249,12 +242,7 @@ class TimetableBloc renew: renew, onError: onError, ); - add( - Emit( - (s) => - s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1), - ), - ); + add(Emit((s) => s.withReferenceData(customEvents: events))); } catch (e) { onError?.call(e); } @@ -262,11 +250,7 @@ class TimetableBloc Future _refreshCustomEvents() async { final events = await repo.data.getCustomEvents(renew: true); - add( - DataGathered( - (s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1), - ), - ); + add(DataGathered((s) => s.withReferenceData(customEvents: events))); } void _prefetchAdjacentWeeks(DateTime start, DateTime end) { @@ -282,19 +266,7 @@ class TimetableBloc } void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) { - final key = weekStart.weekKey(); - add( - Emit((s) { - final updated = Map.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); + add(Emit((s) => s.withFetchedWeek(weekStart, week))); } static DateTime _endOfWeek(DateTime reference) { diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart index 78fea8c..5af24f4 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -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_week/timetable_get_week_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.g.dart'; @@ -40,6 +42,62 @@ abstract class TimetableState with _$TimetableState { Iterable getAllKnownLessons() => 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? 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 /// only exist for subjects that support them; requiring them elsewhere would /// keep foreign plans loading forever. diff --git a/lib/state/app/modules/timetable/bloc/week_cache.dart b/lib/state/app/modules/timetable/bloc/week_cache.dart new file mode 100644 index 0000000..c1d3c9e --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/week_cache.dart @@ -0,0 +1,41 @@ +import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import '../../../../../extensions/date_time.dart'; +import '../../../../../utils/json_equality.dart'; + +/// Weeks kept around the viewed week and around today's week. Everything +/// further away is dropped: every state emit re-serializes the whole cache for +/// HydratedBloc and the calendar rebuilds its appointments from all of it, so +/// an unbounded cache makes week swipes slower the longer the app is used. +const int kWeekCacheRadius = 8; + +/// Returns the cache with [week] stored under [weekStart], pruned to the +/// weeks near [viewedWeekStart] or [now]. Returns null when the stored week +/// already has identical content, so callers can skip the emit entirely. +Map? mergeWeekIntoCache( + Map cache, + DateTime weekStart, + TimetableGetWeekResponse week, { + required DateTime viewedWeekStart, + required DateTime now, +}) { + final key = weekStart.weekKey(); + final existing = cache[key]; + if (sameJson(existing, week)) return null; + + final viewedMonday = viewedWeekStart.mondayOfWeek; + final todayMonday = now.mondayOfWeek; + bool isNear(DateTime monday, DateTime anchor) => + monday.difference(anchor).inDays.abs() <= kWeekCacheRadius * 7 + 1; + + final updated = {}; + for (final entry in cache.entries) { + final monday = DateTime.tryParse(entry.key); + if (monday == null || + isNear(monday, viewedMonday) || + isNear(monday, todayMonday)) { + updated[entry.key] = entry.value; + } + } + updated[key] = week; + return updated; +} diff --git a/lib/storage/account_storage.dart b/lib/storage/account_storage.dart index 7f54460..8726c9e 100644 --- a/lib/storage/account_storage.dart +++ b/lib/storage/account_storage.dart @@ -2,17 +2,15 @@ import 'dart:developer'; import 'dart:io'; import 'package:hydrated_bloc/hydrated_bloc.dart'; -import 'package:localstore/localstore.dart'; -import '../api/request_cache.dart'; +import '../api/cache_store.dart'; import '../session/account_codec.dart'; import 'hydrated_storage_bootstrap.dart'; /// Per-account local data: every account gets its own HydratedBloc box and -/// Localstore cache collection, so switching back is instant and offline. -/// Only the app settings live in the shared [global] box. +/// request cache directory (see [CacheStore]), so switching back is instant +/// and offline. Only the app settings live in the shared [global] box. abstract final class AccountStorage { - static const _legacyCollection = 'MarianumMobile'; static const _settingsKey = 'SettingsCubit'; static late String _baseDir; @@ -36,17 +34,13 @@ abstract final class AccountStorage { } } - /// Points HydratedBloc and the request cache at [account]'s data. Blocs - /// keep the storage they were created with, so the per-account blocs have - /// to be recreated afterwards. + /// Points HydratedBloc at [account]'s data; the request cache follows the + /// active account on its own. Blocs keep the storage they were created + /// with, so the per-account blocs have to be recreated afterwards. static Future activate(AccountEntry? account) async { - if (account == null) { - HydratedBloc.storage = InMemoryStorage(); - RequestCache.collection = _legacyCollection; - return; - } - HydratedBloc.storage = await _storageFor(account.namespace); - RequestCache.collection = cacheCollection(account.namespace); + HydratedBloc.storage = account == null + ? InMemoryStorage() + : await _storageFor(account.namespace); } /// Removes all local data of the account with [namespace]. @@ -62,15 +56,12 @@ abstract final class AccountStorage { final dir = Directory(_dirFor(namespace)); if (dir.existsSync()) await dir.delete(recursive: true); } - await Localstore.instance.collection(cacheCollection(namespace)).delete(); } on Object catch (e, s) { log('Account storage delete failed: $e', stackTrace: s); } + await CacheStore.instance.deleteNamespace(namespace); } - static String cacheCollection(String namespace) => - namespace.isEmpty ? _legacyCollection : '$_legacyCollection-$namespace'; - static Future _storageFor(String namespace) async => _open[namespace] ??= await buildHydratedStorageWithFallback( _ensureDir(_dirFor(namespace)), diff --git a/lib/storage/dev_tools_settings.dart b/lib/storage/dev_tools_settings.dart index a50f540..b9d8be2 100644 --- a/lib/storage/dev_tools_settings.dart +++ b/lib/storage/dev_tools_settings.dart @@ -8,8 +8,22 @@ enum MarianumConnectEndpoint { live, beta, custom } @JsonSerializable() class DevToolsSettings { bool showPerformanceOverlay; - bool checkerboardOffscreenLayers; - bool checkerboardRasterCacheImages; + + @JsonKey(defaultValue: false) + bool showFrameStats; + + @JsonKey(defaultValue: false) + bool showMemoryStats; + + @JsonKey(defaultValue: false) + bool showSemanticsDebugger; + + @JsonKey(defaultValue: false) + bool slowAnimations; + + /// Forced text scale factor; `0` follows the system setting. + @JsonKey(defaultValue: 0) + double textScaleOverride; @JsonKey(defaultValue: MarianumConnectEndpoint.live) MarianumConnectEndpoint marianumConnectEndpoint; @@ -24,8 +38,11 @@ class DevToolsSettings { DevToolsSettings({ required this.showPerformanceOverlay, - required this.checkerboardOffscreenLayers, - required this.checkerboardRasterCacheImages, + this.showFrameStats = false, + this.showMemoryStats = false, + this.showSemanticsDebugger = false, + this.slowAnimations = false, + this.textScaleOverride = 0, this.marianumConnectEndpoint = MarianumConnectEndpoint.live, this.marianumConnectCustomUrl = '', this.emergencyNoticeUrl = '', @@ -51,7 +68,8 @@ class DevToolsSettings { /// Compiled-in source for the backend-independent emergency notice. Must live /// on infrastructure that is reachable even when MarianumConnect is down. - static const String emergencyNoticeDefaultUrl = 'https://www.marianum-fulda.de/~aushang/marMobile.override'; + static const String emergencyNoticeDefaultUrl = + 'https://www.marianum-fulda.de/~aushang/marMobile.override'; String? resolveEmergencyNoticeUrl() => sanitizeCustomUrl(emergencyNoticeUrl) ?? diff --git a/lib/storage/dev_tools_settings.g.dart b/lib/storage/dev_tools_settings.g.dart index 75540e6..f3f4972 100644 --- a/lib/storage/dev_tools_settings.g.dart +++ b/lib/storage/dev_tools_settings.g.dart @@ -6,27 +6,33 @@ part of 'dev_tools_settings.dart'; // JsonSerializableGenerator // ************************************************************************** -DevToolsSettings _$DevToolsSettingsFromJson( - Map json, -) => DevToolsSettings( - showPerformanceOverlay: json['showPerformanceOverlay'] as bool, - checkerboardOffscreenLayers: json['checkerboardOffscreenLayers'] as bool, - checkerboardRasterCacheImages: json['checkerboardRasterCacheImages'] as bool, - marianumConnectEndpoint: - $enumDecodeNullable( - _$MarianumConnectEndpointEnumMap, - json['marianumConnectEndpoint'], - ) ?? - MarianumConnectEndpoint.live, - marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '', - emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '', -); +DevToolsSettings _$DevToolsSettingsFromJson(Map json) => + DevToolsSettings( + showPerformanceOverlay: json['showPerformanceOverlay'] as bool, + showFrameStats: json['showFrameStats'] as bool? ?? false, + showMemoryStats: json['showMemoryStats'] as bool? ?? false, + showSemanticsDebugger: json['showSemanticsDebugger'] as bool? ?? false, + slowAnimations: json['slowAnimations'] as bool? ?? false, + textScaleOverride: (json['textScaleOverride'] as num?)?.toDouble() ?? 0, + marianumConnectEndpoint: + $enumDecodeNullable( + _$MarianumConnectEndpointEnumMap, + json['marianumConnectEndpoint'], + ) ?? + MarianumConnectEndpoint.live, + marianumConnectCustomUrl: + json['marianumConnectCustomUrl'] as String? ?? '', + emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '', + ); Map _$DevToolsSettingsToJson(DevToolsSettings instance) => { 'showPerformanceOverlay': instance.showPerformanceOverlay, - 'checkerboardOffscreenLayers': instance.checkerboardOffscreenLayers, - 'checkerboardRasterCacheImages': instance.checkerboardRasterCacheImages, + 'showFrameStats': instance.showFrameStats, + 'showMemoryStats': instance.showMemoryStats, + 'showSemanticsDebugger': instance.showSemanticsDebugger, + 'slowAnimations': instance.slowAnimations, + 'textScaleOverride': instance.textScaleOverride, 'marianumConnectEndpoint': _$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!, 'marianumConnectCustomUrl': instance.marianumConnectCustomUrl, diff --git a/lib/storage/hydrated_storage_bootstrap.dart b/lib/storage/hydrated_storage_bootstrap.dart index 4a44969..20b9bfc 100644 --- a/lib/storage/hydrated_storage_bootstrap.dart +++ b/lib/storage/hydrated_storage_bootstrap.dart @@ -9,19 +9,43 @@ import 'package:hydrated_bloc/hydrated_bloc.dart'; Future buildHydratedStorageWithFallback(String directoryPath) async { final directory = HydratedStorageDirectory(directoryPath); try { - return await HydratedStorage.build(storageDirectory: directory); + return await _build(directory); } catch (e, s) { log('HydratedStorage open failed, rebuilding: $e', stackTrace: s); } try { await _deleteHydratedBox(directoryPath); - return await HydratedStorage.build(storageDirectory: directory); + return await _build(directory); } catch (e, s) { log('HydratedStorage rebuild failed, using memory: $e', stackTrace: s); return InMemoryStorage(); } } +final Expando Function()> _keyReaders = Expando(); + +Future _build(HydratedStorageDirectory directory) async { + final storage = await HydratedStorage.build(storageDirectory: directory); + // hydrated_bloc exposes no key listing; the Hive instance is the only way + // in. Every build replaces the static one, so it is captured per storage. + // ignore: invalid_use_of_visible_for_testing_member + final hive = HydratedStorage.hive; + _keyReaders[storage] = () => + hive.box('hydrated_box').keys.map((k) => '$k').toList(); + return storage; +} + +/// All keys persisted in [storage], sorted; `null` when they can't be +/// enumerated. +List? hydratedStorageKeys(Storage storage) { + if (storage is InMemoryStorage) return storage.keys.toList()..sort(); + try { + return _keyReaders[storage]?.call()?..sort(); + } on Object { + return null; + } +} + Future _deleteHydratedBox(String directoryPath) async { final directory = Directory(directoryPath); if (!directory.existsSync()) return; @@ -37,6 +61,8 @@ Future _deleteHydratedBox(String directoryPath) async { class InMemoryStorage implements Storage { final Map _values = {}; + Iterable get keys => _values.keys; + @override dynamic read(String key) => _values[key]; diff --git a/lib/storage/modules_settings.dart b/lib/storage/modules_settings.dart index 0706b9c..a3bc941 100644 --- a/lib/storage/modules_settings.dart +++ b/lib/storage/modules_settings.dart @@ -10,12 +10,16 @@ class ModulesSettings { List hiddenModules; bool autoFillBottomBar; int fixedBottomBarSlots; + // Set once the teacher module defaults were applied, so a module the teacher + // re-enables afterwards stays visible. + bool teacherDefaultsApplied; ModulesSettings({ required this.moduleOrder, required this.hiddenModules, this.autoFillBottomBar = true, this.fixedBottomBarSlots = 3, + this.teacherDefaultsApplied = false, }); factory ModulesSettings.fromJson(Map json) => diff --git a/lib/storage/modules_settings.g.dart b/lib/storage/modules_settings.g.dart index d792866..71454b6 100644 --- a/lib/storage/modules_settings.g.dart +++ b/lib/storage/modules_settings.g.dart @@ -16,6 +16,7 @@ ModulesSettings _$ModulesSettingsFromJson(Map json) => .toList(), autoFillBottomBar: json['autoFillBottomBar'] as bool? ?? true, fixedBottomBarSlots: (json['fixedBottomBarSlots'] as num?)?.toInt() ?? 3, + teacherDefaultsApplied: json['teacherDefaultsApplied'] as bool? ?? false, ); Map _$ModulesSettingsToJson( @@ -27,6 +28,7 @@ Map _$ModulesSettingsToJson( .toList(), 'autoFillBottomBar': instance.autoFillBottomBar, 'fixedBottomBarSlots': instance.fixedBottomBarSlots, + 'teacherDefaultsApplied': instance.teacherDefaultsApplied, }; const _$ModulesEnumMap = { diff --git a/lib/utils/directory_size.dart b/lib/utils/directory_size.dart new file mode 100644 index 0000000..b71aa88 --- /dev/null +++ b/lib/utils/directory_size.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +/// Total byte size of all files below [dir]; `0` when it doesn't exist or +/// can't be listed. +Future directorySize(Directory dir) async { + var sum = 0; + try { + if (!dir.existsSync()) return 0; + await for (final entity in dir.list(recursive: true, followLinks: false)) { + if (entity is File) sum += await entity.length(); + } + } on FileSystemException { + // Files can vanish mid-walk (cache pruning); report what was counted. + } + return sum; +} diff --git a/lib/utils/json_equality.dart b/lib/utils/json_equality.dart new file mode 100644 index 0000000..295d7e6 --- /dev/null +++ b/lib/utils/json_equality.dart @@ -0,0 +1,7 @@ +import 'dart:convert'; + +/// Content equality for API models that define no `==` but serialise to JSON. +/// Comparing the encoded strings is cheaper than a deep map walk and treats +/// both sides identically (same `toJson`, same field order). +bool sameJson(Object? a, Object? b) => + a != null && b != null && jsonEncode(a) == jsonEncode(b); diff --git a/lib/utils/recurrence_occurrences.dart b/lib/utils/recurrence_occurrences.dart new file mode 100644 index 0000000..376424e --- /dev/null +++ b/lib/utils/recurrence_occurrences.dart @@ -0,0 +1,75 @@ +import 'package:rrule/rrule.dart'; + +/// Memoised RRULE expansion. +/// +/// `RecurrenceRule.getInstances` always starts at the series anchor, so asking +/// for one week of a daily event created two years ago walks ~750 occurrences +/// (tens of ms each time on older phones) — and the calendar asks once per +/// week and event, the home widget twice per publish. Here each series keeps +/// its parsed rule, the occurrences found so far and the live iterator, so +/// later queries only extend the expansion instead of restarting it. +class RecurrenceOccurrences { + RecurrenceOccurrences._(); + + static const int _maxSeries = 64; + static final Map<(String, DateTime), _Series> _cache = {}; + + /// UTC occurrences of [rule] anchored at [anchorUtc] in + /// `[fromUtc, toUtc)`, in ascending order. Throws like + /// [RecurrenceRule.fromString] for an invalid rule. + static List between( + String rule, + DateTime anchorUtc, + DateTime fromUtc, + DateTime toUtc, + ) { + final key = (rule, anchorUtc); + var series = _cache.remove(key); + series ??= _Series(RecurrenceRule.fromString(rule), anchorUtc); + // Re-insert to keep the map in least-recently-used order. + _cache[key] = series; + if (_cache.length > _maxSeries) _cache.remove(_cache.keys.first); + return series.between(fromUtc, toUtc); + } + + /// Drops all memoised series (tests). + static void clear() => _cache.clear(); +} + +class _Series { + final Iterator _iterator; + final List _found = []; + bool _exhausted = false; + + _Series(RecurrenceRule rule, DateTime anchorUtc) + : _iterator = rule.getInstances(start: anchorUtc).iterator; + + List between(DateTime fromUtc, DateTime toUtc) { + while (!_exhausted && (_found.isEmpty || _found.last.isBefore(toUtc))) { + if (_iterator.moveNext()) { + _found.add(_iterator.current); + } else { + _exhausted = true; + } + } + final start = _lowerBound(fromUtc); + return [ + for (var i = start; i < _found.length && _found[i].isBefore(toUtc); i++) + _found[i], + ]; + } + + int _lowerBound(DateTime value) { + var low = 0; + var high = _found.length; + while (low < high) { + final mid = (low + high) >> 1; + if (_found[mid].isBefore(value)) { + low = mid + 1; + } else { + high = mid; + } + } + return low; + } +} diff --git a/lib/utils/screen_bound_image.dart b/lib/utils/screen_bound_image.dart new file mode 100644 index 0000000..dd97b36 --- /dev/null +++ b/lib/utils/screen_bound_image.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; + +/// Wraps [provider] so it decodes at most [scale] × the screen's longest +/// physical side (capped at [maxPx]). Camera-sized photos otherwise decode at +/// full resolution — 50–200 MB each — which gets the app killed on +/// low-memory devices. +ImageProvider screenBoundImage( + BuildContext context, + ImageProvider provider, { + double scale = 1, + int maxPx = 4096, +}) { + final bound = + (MediaQuery.sizeOf(context).longestSide * + MediaQuery.devicePixelRatioOf(context) * + scale) + .round() + .clamp(1, maxPx); + return ResizeImage( + provider, + width: bound, + height: bound, + policy: ResizeImagePolicy.fit, + ); +} diff --git a/lib/utils/session_single_flight.dart b/lib/utils/session_single_flight.dart new file mode 100644 index 0000000..ad159988 --- /dev/null +++ b/lib/utils/session_single_flight.dart @@ -0,0 +1,21 @@ +import '../session/session_manager.dart'; + +/// Joins concurrent calls into the one already running for the same account +/// session. A run left over from a signed-out session never blocks the next +/// account's first call. +class SessionSingleFlight { + Future? _running; + int? _epoch; + + Future run(Future Function() action) { + final epoch = SessionManager().sessionEpoch; + final running = _running; + if (running != null && _epoch == epoch) return running; + _epoch = epoch; + late final Future current; + current = action().whenComplete(() { + if (identical(_running, current)) _running = null; + }); + return _running = current; + } +} diff --git a/lib/view/login/guardian_login_controller.dart b/lib/view/login/guardian_login_controller.dart index 1c681c1..a66d4be 100644 --- a/lib/view/login/guardian_login_controller.dart +++ b/lib/view/login/guardian_login_controller.dart @@ -11,6 +11,7 @@ import '../../api/marianumconnect/queries/auth_guardian/guardian_login_exception import '../../auth_link/device_binding.dart'; import '../../auth_link/guardian_login_link.dart'; import '../../auth_link/pending_guardian_request.dart'; +import '../../model/session_wipe.dart'; import '../../session/session.dart'; import '../../session/session_manager.dart'; import '../../widget_data/widget_sync.dart'; @@ -216,6 +217,9 @@ class GuardianLoginController extends ChangeNotifier { } static Future _defaultSignIn(Session session) async { + // The previous account's wipe runs deferred; racing it would let it + // clear what this login stores. + await SessionWipe.done; // Sign in first: the one-time code is already spent at this point, so a // failing widget reset must not cost the session (the user would have to // request a fresh mail for a login that actually succeeded). diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart index 8c71406..f3c781b 100644 --- a/lib/view/login/login_controller.dart +++ b/lib/view/login/login_controller.dart @@ -10,6 +10,7 @@ import '../../api/marianumconnect/auth/device_token_name.dart'; import '../../api/marianumconnect/auth/token_storage.dart'; import '../../api/marianumconnect/queries/auth_login/auth_login.dart'; import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart'; +import '../../model/session_wipe.dart'; import '../../session/session.dart'; import '../../session/session_manager.dart'; import '../../widget_data/widget_sync.dart'; @@ -47,6 +48,10 @@ class LoginController extends ChangeNotifier { _errorDetails = null; notifyListeners(); + // The previous account's wipe runs deferred; racing it would let it + // clear what this login stores (widget job, caches). + await SessionWipe.done; + final user = username.trim().toLowerCase(); // Demo login: the prefix enters local demo mode, password ignored, no diff --git a/lib/view/login/widgets/login_form_parts.dart b/lib/view/login/widgets/login_form_parts.dart index 7299640..6f6d6b2 100644 --- a/lib/view/login/widgets/login_form_parts.dart +++ b/lib/view/login/widgets/login_form_parts.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -/// Filled, borderless text field look shared by both login cards. +/// Filled, borderless text field look shared by the login cards. InputDecoration loginInputDecoration( ThemeData theme, String label, @@ -24,7 +24,7 @@ InputDecoration loginInputDecoration( ), ); -/// Card frame with heading and hint line shared by both login cards. +/// Card frame with heading and hint line shared by the login cards. class LoginCardFrame extends StatelessWidget { final String title; final String hint; diff --git a/lib/view/pages/files/data/sort_options.dart b/lib/view/pages/files/data/sort_options.dart index 3532560..e9d263d 100644 --- a/lib/view/pages/files/data/sort_options.dart +++ b/lib/view/pages/files/data/sort_options.dart @@ -21,7 +21,7 @@ class SortOptions { SortOption.name: BetterSortOption( displayName: 'Name', icon: Icons.sort_by_alpha_outlined, - compare: (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + compare: (a, b) => a.lowerName.compareTo(b.lowerName), ), SortOption.date: BetterSortOption( displayName: 'Datum', diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 649427b..ffcacf4 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import '../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; @@ -47,6 +49,22 @@ class _FilesViewState extends State<_FilesView> { late bool currentSortDirection; late final StreamSubscription _invalidationSub; + // The list builder also runs for loading flips and parent rebuilds; only a + // new listing or a changed sort needs another sort pass. + Object? _sortedKey; + List _sortedFiles = const []; + + List _sorted(ListFilesResponse listing, bool foldersToTop) { + final key = (listing, currentSort, currentSortDirection, foldersToTop); + if (key == _sortedKey) return _sortedFiles; + _sortedKey = key; + return _sortedFiles = listing.sortBy( + sortOption: currentSort, + foldersToTop: foldersToTop, + reversed: currentSortDirection, + ); + } + // Cache key in FilesBloc's pathString format: '/' for root, otherwise // segments joined without leading/trailing slash. String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/'); @@ -98,6 +116,11 @@ class _FilesViewState extends State<_FilesView> { @override Widget build(BuildContext context) { final bloc = context.read(); + // Selected here, not in the LoadableStateConsumer child: that closure runs + // during the consumer's build, where this context may not subscribe. + final foldersToTop = context.select( + (SettingsCubit c) => c.state.fileSettings.sortFoldersToTop, + ); return Scaffold( appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), @@ -153,15 +176,7 @@ class _FilesViewState extends State<_FilesView> { text: 'Der Ordner ist leer', ); } - final files = listing.sortBy( - sortOption: currentSort, - foldersToTop: context - .watch() - .val() - .fileSettings - .sortFoldersToTop, - reversed: currentSortDirection, - ); + final files = _sorted(listing, foldersToTop); return ListView.builder( padding: EdgeInsets.zero, itemCount: files.length, diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index 95502b7..2b63619 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -53,15 +53,6 @@ class _FilesUploadDialogState extends State { }).toList(); } - void showHttpErrorCode(int httpErrorCode) { - InfoDialog.show( - context, - 'Error code: $httpErrorCode', - title: 'Ein Fehler ist aufgetreten', - copyable: true, - ); - } - void _resetProgress() { _isUploading = false; _overallProgressValue = 0.0; @@ -88,6 +79,7 @@ class _FilesUploadDialogState extends State { }); final webdavClient = await WebdavApi.webdav; + if (!mounted) return; if (!override) { List result; @@ -157,6 +149,7 @@ class _FilesUploadDialogState extends State { ), ); + if (!mounted) return; if (replaceFiles != true) { setState(() { _resetProgress(); @@ -200,18 +193,23 @@ class _FilesUploadDialogState extends State { } final HttpClientResponse uploadTask; + final fileIndex = _uploadableFiles.indexOf(file); + var lastPercent = -1; try { uploadTask = await webdavClient.putFile( File(filePath), fileStat, PathUri.parse(fullRemotePath), onProgress: (progress) { + // Called per 64 KB chunk — thousands of times for a video. Only + // rebuild when the visible percentage actually moves. + final percent = (progress * 100).floor(); + if (!mounted || percent == lastPercent) return; + lastPercent = percent; setState(() { file._uploadProgress = progress; _overallProgressValue = - ((progress + _uploadableFiles.indexOf(file)) / - _uploadableFiles.length) - .toDouble(); + ((progress + fileIndex) / _uploadableFiles.length).toDouble(); }); }, ); @@ -221,18 +219,21 @@ class _FilesUploadDialogState extends State { return; } + if (!mounted) return; + // Stay on the dialog: popping here reported the partial result as a + // success to onUploadFinished and closed the error dialog right away. if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { - setState(_resetProgress); - if (!mounted) return; - Navigator.of(context).pop(); - showHttpErrorCode(uploadTask.statusCode); - } else { - uploadetFilePaths.add(fullRemotePath); + _showUploadError( + 'Upload fehlgeschlagen für "$fileName" ' + '(Fehlercode ${uploadTask.statusCode}).', + ); + return; } + uploadetFilePaths.add(fullRemotePath); } - setState(_resetProgress); if (!mounted) return; + setState(_resetProgress); Navigator.of(context).pop(); widget.onUploadFinished(uploadetFilePaths); } @@ -252,7 +253,12 @@ class _FilesUploadDialogState extends State { itemCount: _uploadableFiles.length, itemBuilder: (context, index) { final currentFile = _uploadableFiles[index]; - currentFile.fileNameController.text = currentFile.fileName; + // Only sync when it differs: assigning text resets selection + // and notifies the field on every (progress) rebuild. + if (currentFile.fileNameController.text != + currentFile.fileName) { + currentFile.fileNameController.text = currentFile.fileName; + } return ListTile( title: TextField( readOnly: _isUploading, diff --git a/lib/view/pages/files/search/files_search_controller.dart b/lib/view/pages/files/search/files_search_controller.dart index 21e0399..1086a20 100644 --- a/lib/view/pages/files/search/files_search_controller.dart +++ b/lib/view/pages/files/search/files_search_controller.dart @@ -26,6 +26,14 @@ class FilesSearchController extends ChangeNotifier { Object? _serverError; int _serverEpoch = 0; bool _disposed = false; + Future>? _localIndex; + + Future> _searchLocal(List pathScope) async => + searchLocalCacheIndex( + await (_localIndex ??= loadLocalCacheIndex()), + _query, + pathScope: pathScope, + ); /// Guards against the race where the search delegate is closed (and the /// controller disposed) while a debounced cache scan or server call is @@ -80,7 +88,7 @@ class FilesSearchController extends ChangeNotifier { _serverError = null; _safeNotify(); - final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope); + final cacheHits = await _searchLocal(_pathScope); if (epoch != _serverEpoch) return; _cacheResults = cacheHits; _safeNotify(); @@ -101,7 +109,7 @@ class FilesSearchController extends ChangeNotifier { _serverError = null; _safeNotify(); - final cacheHits = await searchLocalCaches(_query); + final cacheHits = await _searchLocal(const []); if (epoch != _serverEpoch) return; _cacheResults = cacheHits; _safeNotify(); diff --git a/lib/view/pages/files/search/files_search_results.dart b/lib/view/pages/files/search/files_search_results.dart index ff9b611..69db680 100644 --- a/lib/view/pages/files/search/files_search_results.dart +++ b/lib/view/pages/files/search/files_search_results.dart @@ -104,32 +104,43 @@ class FilesSearchResults extends StatelessWidget { Widget _resultList(BuildContext context, List combined) { final groups = _groupByParent(combined); final orderedKeys = groups.keys.toList()..sort(); - final items = []; - for (final folder in orderedKeys) { - final segments = _segmentsOf(folder); - items.add( - _FolderHeader( - folder: folder, - onOpen: () { - onResultTap?.call(); - AppRoutes.openFolder(context, segments); - }, - ), - ); - for (final file in groups[folder]!) { - items.add( - FileElement( - file, - segments, - controller.retry, - highlight: controller.query, - ), + // Flat (folder header | file) rows built lazily: results can run into + // the hundreds and arrive in several batches per query. + final rows = <(String, CacheableFile?)>[ + for (final folder in orderedKeys) ...[ + (folder, null), + for (final file in groups[folder]!) (folder, file), + ], + ]; + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: rows.length, + itemBuilder: (context, index) { + final (folder, file) = rows[index]; + final segments = _segmentsOf(folder); + if (file == null) { + return _FolderHeader( + key: ValueKey('folder:$folder'), + folder: folder, + onOpen: () { + onResultTap?.call(); + AppRoutes.openFolder(context, segments); + }, + ); + } + return FileElement( + file, + segments, + controller.retry, + key: ValueKey('file:${file.path}'), + highlight: controller.query, ); - } - } - return ListView(padding: EdgeInsets.zero, children: items); + }, + ); } + static final RegExp _edgeSlashes = RegExp(r'^/+|/+$'); + Map> _groupByParent(List files) { final map = >{}; for (final file in files) { @@ -139,7 +150,7 @@ class FilesSearchResults extends StatelessWidget { } String _parentOf(CacheableFile file) { - final stripped = file.path.replaceAll(RegExp(r'^/+|/+$'), ''); + final stripped = file.path.replaceAll(_edgeSlashes, ''); final segments = stripped.split('/'); if (segments.length <= 1) return '/'; segments.removeLast(); @@ -147,7 +158,7 @@ class FilesSearchResults extends StatelessWidget { } List _segmentsOf(String folder) { - final stripped = folder.replaceAll(RegExp(r'^/+|/+$'), ''); + final stripped = folder.replaceAll(_edgeSlashes, ''); if (stripped.isEmpty) return const []; return stripped.split('/'); } @@ -156,7 +167,11 @@ class FilesSearchResults extends StatelessWidget { class _FolderHeader extends StatelessWidget { final String folder; final VoidCallback onOpen; - const _FolderHeader({required this.folder, required this.onOpen}); + const _FolderHeader({ + required this.folder, + required this.onOpen, + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/view/pages/files/search/local_cache_search.dart b/lib/view/pages/files/search/local_cache_search.dart index 45a14e4..ea090da 100644 --- a/lib/view/pages/files/search/local_cache_search.dart +++ b/lib/view/pages/files/search/local_cache_search.dart @@ -1,49 +1,29 @@ import 'dart:convert'; -import 'package:localstore/localstore.dart'; +import 'package:flutter/foundation.dart'; +import '../../../../api/cache_store.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; -import '../../../../api/request_cache.dart'; /// Document key prefix used by `ListFilesCache._documentId`. const String _folderCachePrefix = 'wd-folder-'; -/// Scans every cached folder listing in Localstore and returns files/folders -/// whose name contains [query] (case-insensitive). -/// -/// [pathScope] restricts results to entries whose WebDAV path starts with -/// the given folder. Pass an empty list (or null) to search globally. -/// -/// [docs] is an injection seam for tests — production callers leave it null -/// so the helper reads from the real Localstore. -Future> searchLocalCaches( - String query, { - List? pathScope, - Map? docs, -}) async { - final trimmed = query.trim(); - if (trimmed.isEmpty) return const []; - final needle = trimmed.toLowerCase(); - final scopePrefix = pathScope == null || pathScope.isEmpty - ? '' - : '${pathScope.join('/')}/'; - - final raw = - docs ?? - await Localstore.instance.collection(RequestCache.collection).get(); - if (raw == null || raw.isEmpty) return const []; - - final results = {}; - for (final entry in raw.entries) { - final docKey = entry.key.split('/').last; - if (!docKey.startsWith(_folderCachePrefix)) continue; - - final value = entry.value; - if (value is! Map) continue; - final json = value['json']; - if (json is! String) continue; +/// Every file and folder from the cached folder listings, deduplicated by +/// path. Built once per search session: reading and parsing all listings on +/// each keystroke used to stall typing. +Future> loadLocalCacheIndex() async { + final entries = await CacheStore.instance.readAll(prefix: _folderCachePrefix); + if (entries.isEmpty) return const []; + final payloads = [for (final entry in entries.values) entry.json]; + return compute(buildLocalCacheIndex, payloads); +} +/// Parses cached `ListFilesResponse` payloads into a deduplicated file list. +/// Unparsable payloads are skipped. +List buildLocalCacheIndex(List payloads) { + final byPath = {}; + for (final json in payloads) { final ListFilesResponse listing; try { listing = ListFilesResponse.fromJson( @@ -52,14 +32,32 @@ Future> searchLocalCaches( } on Object { continue; } - for (final file in listing.files) { - if (!file.name.toLowerCase().contains(needle)) continue; - if (scopePrefix.isNotEmpty && !file.path.startsWith(scopePrefix)) { - continue; - } - results[file.path] ??= file; + byPath[file.path] ??= file; } } - return results.values.toList(); + return byPath.values.toList(); +} + +/// Files in [index] whose name contains [query] (case-insensitive). +/// +/// [pathScope] restricts results to entries whose WebDAV path starts with +/// the given folder. Pass an empty list (or null) to search globally. +List searchLocalCacheIndex( + List index, + String query, { + List? pathScope, +}) { + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + final needle = trimmed.toLowerCase(); + final scopePrefix = pathScope == null || pathScope.isEmpty + ? '' + : '${pathScope.join('/')}/'; + return [ + for (final file in index) + if (file.lowerName.contains(needle) && + (scopePrefix.isEmpty || file.path.startsWith(scopePrefix))) + file, + ]; } diff --git a/lib/view/pages/foreign_timetable/element_picker_page.dart b/lib/view/pages/foreign_timetable/element_picker_page.dart index 61db705..d9ee475 100644 --- a/lib/view/pages/foreign_timetable/element_picker_page.dart +++ b/lib/view/pages/foreign_timetable/element_picker_page.dart @@ -7,6 +7,7 @@ import '../../../api/marianumconnect/queries/timetable_get_element_week/timetabl import '../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart'; import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart'; +import '../../../session/session_manager.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../storage/timetable_favorites_settings.dart'; import '../../../utils/haptics.dart'; @@ -31,7 +32,14 @@ class _ElementPickerPageState extends State { // One in-flight/resolved future per type so switching tabs (or rebuilds) // never re-fetches a list that's already loaded. - final Map>> _futures = {}; + // Session-wide (per account): the student list alone has 1000+ entries and + // used to be downloaded again every time the picker opened. + static final Map< + TimetableElementType, + ({int epoch, DateTime at, Future> items}) + > + _futures = {}; + static const Duration _listMaxAge = Duration(minutes: 15); // Memoised combined future for the "Alle" tab; rebuilt on retry. Future>? _allFuture; @@ -47,8 +55,25 @@ class _ElementPickerPageState extends State { return _allFuture ??= _loadAll(); } - Future> _loadFor(TimetableElementType type) => - _futures.putIfAbsent(type, () => _fetch(type)); + Future> _loadFor(TimetableElementType type) { + final epoch = SessionManager().sessionEpoch; + final cached = _futures[type]; + if (cached != null && + cached.epoch == epoch && + DateTime.now().difference(cached.at) < _listMaxAge) { + return cached.items; + } + final items = _fetch(type); + _futures[type] = (epoch: epoch, at: DateTime.now(), items: items); + // A failed load must not stick for the rest of the session. + items.then( + (_) {}, + onError: (Object _) { + if (identical(_futures[type]?.items, items)) _futures.remove(type); + }, + ); + return items; + } Future> _loadAll() async { final lists = await Future.wait(TimetableElementType.values.map(_loadFor)); @@ -117,20 +142,16 @@ class _ElementPickerPageState extends State { Haptics.selection(); // Hand the selection back to the timetable view, which renders the foreign // plan inline. We do not navigate to a new page. - Navigator.of(context).pop(( - type: item.type, - id: item.id, - label: item.primary, - )); + Navigator.of( + context, + ).pop((type: item.type, id: item.id, label: item.primary)); } void _openFavorite(FavoriteTimetableElement favorite) { Haptics.selection(); - Navigator.of(context).pop(( - type: favorite.type, - id: favorite.id, - label: favorite.label, - )); + Navigator.of( + context, + ).pop((type: favorite.type, id: favorite.id, label: favorite.label)); } void _toggleFavorite(TimetableElementType type, int id, String label) { @@ -325,9 +346,7 @@ class _ElementPickerPageState extends State { return ListTile( leading: Icon(_iconFor(item.type)), title: Text(item.primary), - subtitle: subtitleParts.isEmpty - ? null - : Text(subtitleParts.join(' · ')), + subtitle: subtitleParts.isEmpty ? null : Text(subtitleParts.join(' · ')), trailing: IconButton( icon: Icon(isFavorite ? Icons.star : Icons.star_border), tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren', diff --git a/lib/view/pages/settings/chat_background_settings_page.dart b/lib/view/pages/settings/chat_background_settings_page.dart index 0012913..9bc47b0 100644 --- a/lib/view/pages/settings/chat_background_settings_page.dart +++ b/lib/view/pages/settings/chat_background_settings_page.dart @@ -214,8 +214,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget { final file = File(AppPaths.chatBackgroundImage); await file.writeAsBytes(bytes); // Same filename across replacements → the decoded image is cached under - // an identical key. Evict so the new bytes actually show. - await FileImage(file).evict(); + // an identical key (for "cover" also wrapped in a viewport-sized + // ResizeImage). Clearing the cache is fine for this rare action. + PaintingBinding.instance.imageCache + ..clear() + ..clearLiveImages(); final cs = settings.val(write: true).chatBackgroundSettings; cs.imageVersion++; cs.type = ChatBackgroundType.image; diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 40b8465..86d0b7c 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -71,8 +71,6 @@ class DefaultSettings { fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS), notificationSettings: NotificationSettings(enabled: true), devToolsSettings: DevToolsSettings( - checkerboardOffscreenLayers: false, - checkerboardRasterCacheImages: false, showPerformanceOverlay: false, marianumConnectEndpoint: MarianumConnectEndpoint.live, marianumConnectCustomUrl: '', diff --git a/lib/view/pages/settings/diagnostics_page.dart b/lib/view/pages/settings/diagnostics_page.dart new file mode 100644 index 0000000..695ed88 --- /dev/null +++ b/lib/view/pages/settings/diagnostics_page.dart @@ -0,0 +1,227 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../../api/marianumconnect/auth/device_token_name.dart'; +import '../../../api/marianumconnect/auth/token_storage.dart'; +import '../../../api/marianumconnect/marianumconnect_endpoint.dart'; +import '../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart'; +import '../../../background/widget_background_task.dart'; +import '../../../extensions/date_time.dart'; +import '../../../model/endpoint_data.dart'; +import '../../../push/push_registration_store.dart'; +import '../../../push/push_registration_type.dart'; +import '../../../session/account_codec.dart'; +import '../../../session/session_manager.dart'; +import '../../../state/app/modules/account/bloc/account_bloc.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../utils/clipboard_helper.dart'; +import '../../../widget/app_progress_indicator.dart'; +import '../../../widget/async_action_button.dart'; +import '../../../widget/centered_leading.dart'; +import '../../../widget_data/widget_sync.dart'; +import 'widgets/push_status_sheet.dart'; + +typedef _Row = ({String label, String value}); +typedef _Section = ({String title, List<_Row> rows}); + +/// Read-only snapshot of build, endpoint, session, push and widget state for +/// bug reports. Secrets (passwords, bearer token) are never shown. +class DiagnosticsPage extends StatefulWidget { + const DiagnosticsPage({super.key}); + + @override + State createState() => _DiagnosticsPageState(); +} + +class _DiagnosticsPageState extends State { + late Future> _sections = _collect(); + + Future> _collect() async { + final settings = context.read().val(); + final accountStatus = context.read().state.status; + final session = SessionManager().current; + final nextcloud = session?.nextcloud; + const tokens = MarianumConnectTokenStorage(); + final push = PushRegistrationStore(); + + Future safe(Future Function() read) async { + try { + final value = await read().timeout(const Duration(seconds: 5)); + return switch (value) { + null => '–', + bool() => value ? 'ja' : 'nein', + DateTime() => '${value.formatDateTime()} (${value.formatRelative()})', + _ => '$value', + }; + } on Object catch (e) { + return 'Fehler: $e'; + } + } + + Future> rows(Map Function()> reads) => + Future.wait( + reads.entries.map( + (e) async => (label: e.key, value: await safe(e.value)), + ), + ); + + final info = await PackageInfo.fromPlatform(); + return [ + ( + title: 'App', + rows: await rows({ + 'Version': () async => '${info.version} (${info.buildNumber})', + 'Paket': () async => info.packageName, + 'Build-Modus': () async => kReleaseMode + ? 'Release' + : kProfileMode + ? 'Profile' + : 'Debug', + 'Plattform': () async => + '${Platform.operatingSystem} ${Platform.operatingSystemVersion}', + 'Gerät': DeviceTokenName.resolve, + 'Dart': () async => Platform.version.split(' ').first, + }), + ), + ( + title: 'Server', + rows: await rows({ + 'Marianum Connect': () async => MarianumConnectEndpoint.current(), + 'Nextcloud': () async => + 'https://${EndpointData().nextcloud().full()}', + 'Notfall-Nachricht': () async => + settings.devToolsSettings.resolveEmergencyNoticeUrl(), + }), + ), + ( + title: 'Sitzung', + rows: await rows({ + 'Status': () async => accountStatus.name, + 'Benutzer': () async => + session == null ? null : identityOf(session).$2, + 'Konten': () async => SessionManager().accounts.value.accounts.length, + 'Demo-Modus': () async => session?.isDemo ?? false, + 'Login Flow v2': () async => nextcloud?.usesLoginFlow ?? false, + 'App-Passwort (Dateien)': () async => + nextcloud?.hasAppPassword ?? false, + 'App-Passwort (Talk)': () async => + nextcloud?.hasAppPasswordTalk ?? false, + 'Session-Epoche': () async => SessionManager().sessionEpoch, + 'MC-Token-ID': tokens.readTokenId, + 'MC-Token läuft ab': tokens.readExpiresAt, + }), + ), + ( + title: 'Push', + rows: await rows({ + 'Benachrichtigungen': () async => + settings.notificationSettings.enabled, + 'FCM-Token': FirebaseMessaging.instance.getToken, + for (final type in PushRegistrationType.values) ...{ + 'Registriert (${type.name})': () => push.isRegistered(type), + 'Letzte Registrierung (${type.name})': () => + push.lastRegistrationAt(type), + 'Letzter Fehler (${type.name})': () => + push.lastRegistrationError(type), + }, + }), + ), + ( + title: 'Sonstiges', + rows: await rows({ + 'Telemetrie-Geräte-ID': TelemetryDeviceId.resolve, + 'Widget zuletzt abgerufen': WidgetSync.getFetchedAt, + }), + ), + ]; + } + + String _asText(List<_Section> sections) => [ + for (final s in sections) ...[ + '## ${s.title}', + for (final r in s.rows) '${r.label}: ${r.value}', + '', + ], + ].join('\n'); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Diagnose'), + actions: [ + IconButton( + tooltip: 'Neu laden', + icon: const Icon(Icons.refresh), + onPressed: () => setState(() { + _sections = _collect(); + }), + ), + FutureBuilder( + future: _sections, + builder: (context, snapshot) => IconButton( + tooltip: 'Alles kopieren', + icon: const Icon(Icons.copy_all_outlined), + onPressed: snapshot.hasData + ? () => copyToClipboard(context, _asText(snapshot.data!)) + : null, + ), + ), + ], + ), + body: FutureBuilder( + future: _sections, + builder: (context, snapshot) { + final sections = snapshot.data; + if (sections == null) { + return const Center(child: AppProgressIndicator.large()); + } + return ListView( + children: [ + for (final section in sections) ...[ + ListTile( + dense: true, + title: Text( + section.title, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + for (final row in section.rows) + ListTile( + dense: true, + title: Text(row.label), + subtitle: SelectableText(row.value), + onLongPress: () => copyToClipboard(context, row.value), + ), + if (section.title == 'Push') + ListTile( + leading: const CenteredLeading( + Icon(Icons.notifications_active_outlined), + ), + title: const Text('Push-Status prüfen'), + trailing: const Icon(Icons.arrow_right), + onTap: () => showPushStatusSheet(context), + ), + if (section.title == 'Sonstiges') + AsyncListTile( + leading: const CenteredLeading(Icon(Icons.widgets_outlined)), + title: const Text('Widget jetzt aktualisieren'), + closeOnSuccess: false, + onPressed: WidgetBackgroundTask.requestImmediateRefresh, + ), + const Divider(), + ], + ], + ); + }, + ), + ); +} diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index b6c17c8..1262d6e 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -212,14 +212,17 @@ class _SchoolAccountState extends State<_SchoolAccount> { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - displayName ?? username, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, + FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerStart, + child: Text( + displayName ?? username, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + maxLines: 1, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), if (displayName != null) ...[ const SizedBox(height: 2), @@ -304,11 +307,14 @@ class _AccountName extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - name, - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600), - maxLines: 2, - overflow: TextOverflow.ellipsis, + FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerStart, + child: Text( + name, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + maxLines: 1, + ), ), const SizedBox(height: 2), Text( diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart index 2d70847..0066644 100644 --- a/lib/view/pages/settings/sections/dev_tools_section.dart +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -1,19 +1,30 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:path_provider/path_provider.dart'; +import '../../../../api/cache_store.dart'; +import '../../../../push/chat_thread_store.dart'; +import '../../../../push/nid_store.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../storage/dev_tools_settings.dart'; import '../../../../storage/settings.dart' as model; +import '../../../../utils/directory_size.dart'; +import '../../../../utils/downloads/download_manager.dart'; +import '../../../../widget/avatar_disk_cache.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/confirm_dialog.dart'; -import '../../../../widget/debug/cache_view.dart'; -import '../../../../widget/debug/json_viewer.dart'; +import '../../../../widget/debug/bloc_storage_view.dart'; import '../../../../widget/details_bottom_sheet.dart'; +import '../../../../widget/user_avatar.dart'; import '../widgets/endpoint_picker.dart'; import '../widgets/settings_checkbox_tile.dart'; +import '../widgets/settings_dropdown_tile.dart'; class DevToolsSection extends StatefulWidget { final SettingsCubit settings; @@ -27,9 +38,16 @@ class _DevToolsSectionState extends State { @override Widget build(BuildContext context) => Column( children: [ + ListTile( + leading: const CenteredLeading(Icon(Icons.monitor_heart_outlined)), + title: const Text('Diagnose'), + subtitle: const Text('Version, Server, Sitzung, Push'), + trailing: const Icon(Icons.arrow_right), + onTap: () => AppRoutes.openDiagnostics(context), + ), ListTile( leading: const CenteredLeading(Icon(Icons.speed_outlined)), - title: const Text('Performance overlays'), + title: const Text('Debug-Overlays'), trailing: const Icon(Icons.arrow_right), onTap: () { showDetailsBottomSheet( @@ -39,35 +57,57 @@ class _DevToolsSectionState extends State { bloc: widget.settings, builder: (_, _) { final dev = widget.settings.val().devToolsSettings; + DevToolsSettings write() => + widget.settings.val(write: true).devToolsSettings; return Column( mainAxisSize: MainAxisSize.min, children: [ SettingsCheckboxTile( icon: Icons.auto_graph_outlined, - title: 'Performance graph', + title: 'Performance-Graph', value: dev.showPerformanceOverlay, - onChanged: (e) => widget.settings - .val(write: true) - .devToolsSettings - .showPerformanceOverlay = e, + onChanged: (e) => write().showPerformanceOverlay = e, ), SettingsCheckboxTile( - icon: Icons.screen_search_desktop_outlined, - title: 'Indicate offscreen layers', - value: dev.checkerboardOffscreenLayers, - onChanged: (e) => widget.settings - .val(write: true) - .devToolsSettings - .checkerboardOffscreenLayers = e, + icon: Icons.timer_outlined, + title: 'Frame-Statistik', + subtitle: 'Build-/Raster-Zeiten und Jank-Zähler', + value: dev.showFrameStats, + onChanged: (e) => write().showFrameStats = e, ), SettingsCheckboxTile( - icon: Icons.imagesearch_roller_outlined, - title: 'Indicate raster cache images', - value: dev.checkerboardRasterCacheImages, - onChanged: (e) => widget.settings - .val(write: true) - .devToolsSettings - .checkerboardRasterCacheImages = e, + icon: Icons.memory_outlined, + title: 'Speicher-Statistik', + subtitle: 'Prozess-RAM und Bild-Cache', + value: dev.showMemoryStats, + onChanged: (e) => write().showMemoryStats = e, + ), + SettingsCheckboxTile( + icon: Icons.slow_motion_video_outlined, + title: 'Langsame Animationen', + subtitle: '5× verlangsamt', + value: dev.slowAnimations, + onChanged: (e) => write().slowAnimations = e, + ), + SettingsCheckboxTile( + icon: Icons.accessibility_new_outlined, + title: 'Semantics-Debugger', + subtitle: 'Zeigt die Screenreader-Sicht', + value: dev.showSemanticsDebugger, + onChanged: (e) => write().showSemanticsDebugger = e, + ), + SettingsDropdownTile( + icon: Icons.format_size_outlined, + title: 'Textgröße', + value: dev.textScaleOverride, + options: const [0, 0.85, 1, 1.3, 1.6, 2], + optionIcon: (e) => e == 0 + ? Icons.phone_android_outlined + : Icons.text_fields_outlined, + optionLabel: (e) => e == 0 + ? 'System' + : '${e == e.roundToDouble() ? e.toInt() : e}×', + onChanged: (e) => write().textScaleOverride = e, ), ], ); @@ -99,98 +139,204 @@ class _DevToolsSectionState extends State { .trim(); return ListTile( leading: const CenteredLeading(Icon(Icons.emergency_outlined)), - title: const Text('Notfall-Nachricht (Quelle)'), - subtitle: Text( - override.isEmpty - ? 'Standardquelle (im Code hinterlegt)' - : override, - ), + title: const Text('Notfall-Nachrichtenquelle'), + subtitle: Text(override.isEmpty ? 'Standardquelle' : override), trailing: const Icon(Icons.arrow_right), - onTap: () => _EmergencyNoticeUrlEditor.show(context, widget.settings), + onTap: () => + _EmergencyNoticeUrlEditor.show(context, widget.settings), ); }, ), - ListTile( - leading: const CenteredLeading(Icon(Icons.image_outlined)), - title: const Text('Thumb-storage'), - subtitle: Text( - 'etwa ${filesize(PaintingBinding.instance.imageCache.currentSizeBytes)}\nLange tippen um zu löschen', + const Divider(), + _StorageTile( + icon: Icons.settings_applications_outlined, + title: 'Einstellungen', + describe: () async => filesize( + utf8.encode(jsonEncode(widget.settings.val().toJson())).length, ), - onLongPress: () { - ConfirmDialog( - title: 'Thumbs cache löschen', - content: 'Alle zwischengespeicherten Bilder werden gelöscht.', - confirmButton: 'Unwiederruflich löschen', - onConfirm: () => PaintingBinding.instance.imageCache.clear(), - ).asDialog(context); - }, + onOpen: (_) async => AppRoutes.openJsonViewer( + context, + widget.settings.val().toJson(), + title: 'Einstellungen', + ), + clearTitle: 'Einstellungen zurücksetzen', + clearContent: + 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten ' + 'sind nicht betroffen.', + onClear: widget.settings.reset, ), - ListTile( - leading: const CenteredLeading( - Icon(Icons.settings_applications_outlined), - ), - title: const Text('Settings-storage JSON dump'), - subtitle: Text( - 'etwa ${filesize(widget.settings.val().toJson().toString().length * 8)}\nLange tippen um zu löschen', - ), - onTap: () { - JsonViewer.asDialog(context, widget.settings.val().toJson()); + _StorageTile( + icon: Icons.account_tree_outlined, + title: 'BLoC-Speicher', + describe: () async { + final keys = BlocStorageView.keys(); + return keys == null ? 'nicht auflistbar' : '${keys.length} Einträge'; }, - onLongPress: () { - ConfirmDialog( - title: 'Einstellungen löschen', - content: - 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.', - confirmButton: 'Unwiederruflich Löschen', - onConfirm: () { - context.read().reset(); - }, - ).asDialog(context); - }, - trailing: const Icon(Icons.arrow_right), + onOpen: AppRoutes.openBlocStorageView, ), - ListTile( - leading: const CenteredLeading(Icon(Icons.data_object)), - title: const Text('Cache-storage JSON dump'), - subtitle: FutureBuilder( - future: const CacheView().totalSize(), - builder: (context, snapshot) => Text( - "etwa ${snapshot.hasError - ? "?" - : snapshot.hasData - ? filesize(snapshot.data) - : "..."}\nLange tippen um zu löschen", + _StorageTile( + icon: Icons.data_object, + title: 'Request-Cache', + describe: () async => filesize(await CacheStore.instance.totalSize()), + onOpen: AppRoutes.openCacheView, + ), + _StorageTile( + icon: Icons.image_outlined, + title: 'Bild-Speicher (RAM)', + describe: () async { + final cache = PaintingBinding.instance.imageCache; + return '${cache.currentSize} Bilder · ' + '${filesize(cache.currentSizeBytes)} von ' + '${filesize(cache.maximumSizeBytes)}'; + }, + clearTitle: 'Bild-Speicher leeren', + clearContent: + 'Alle dekodierten Bilder im Arbeitsspeicher werden verworfen.', + onClear: () async => PaintingBinding.instance.imageCache.clear(), + ), + _StorageTile( + icon: Icons.photo_library_outlined, + title: 'Netzwerk-Bilder', + describe: () async => filesize( + await directorySize( + Directory( + '${(await getTemporaryDirectory()).path}/${DefaultCacheManager.key}', + ), ), ), - onTap: () => AppRoutes.openCacheView(context), - onLongPress: () { - ConfirmDialog( - title: 'App-Cache löschen', - content: - 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', - confirmButton: 'Unwiederruflich löschen', - onConfirm: () => - const CacheView().clear().then((value) => setState(() {})), - ).asDialog(context); - }, - trailing: const Icon(Icons.arrow_right), + clearTitle: 'Netzwerk-Bilder löschen', + clearContent: + 'Heruntergeladene Chat- und Vorschaubilder werden gelöscht und bei ' + 'Bedarf neu geladen.', + onClear: DefaultCacheManager().emptyCache, ), - ListTile( - leading: const CenteredLeading(Icon(Icons.data_object)), - title: const Text('BLOC-storage state cache'), - subtitle: const Text('Lange tippen um zu löschen'), - onLongPress: () { - ConfirmDialog( - title: 'BLOC-Cache löschen', - content: - 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', - confirmButton: 'Unwiederruflich löschen', - onConfirm: () => HydratedBloc.storage.clear(), - ).asDialog(context); + _StorageTile( + icon: Icons.account_circle_outlined, + title: 'Avatar-Cache', + describe: () async => + filesize(await AvatarDiskCache.instance.totalSize()), + clearTitle: 'Avatar-Cache löschen', + clearContent: 'Profilbilder werden bei Bedarf neu geladen.', + onClear: () async { + invalidateAvatarCache(); + await AvatarDiskCache.instance.clear(); + }, + ), + _StorageTile( + icon: Icons.notifications_outlined, + title: 'Push-Zuordnungen', + describe: () async => + '${(await NidStore().all()).length} Benachrichtigungen zugeordnet', + clearTitle: 'Push-Zuordnungen löschen', + clearContent: + 'Die Zuordnung angezeigter Benachrichtigungen zu Chats und der ' + 'gruppierte Chat-Verlauf gehen verloren. Bereits angezeigte ' + 'Benachrichtigungen werden danach nicht mehr automatisch entfernt.', + onClear: () async { + await NidStore().clear(); + await ChatThreadStore().clearAll(); + }, + ), + _StorageTile( + icon: Icons.download_outlined, + title: 'Downloads', + describe: () async => + filesize(await directorySize(await _downloadsDir())), + clearTitle: 'Downloads löschen', + clearContent: + 'Laufende Downloads werden abgebrochen und heruntergeladene ' + 'Dateien gelöscht.', + onClear: () async { + DownloadManager.instance.clearAll(); + final dir = await _downloadsDir(); + if (!dir.existsSync()) return; + // Cancelled tasks can still be flushing into the folder, so deleting + // it as a whole races them; whatever survives shows up in the size. + await for (final entity in dir.list()) { + try { + await entity.delete(recursive: true); + } on FileSystemException { + // Retried on the next clear. + } + } }, ), ], ); + + static Future _downloadsDir() async => + Directory('${(await getTemporaryDirectory()).path}/downloads'); +} + +/// Storage row: tap opens a detail view ([onOpen]), the trailing button clears +/// it after confirmation ([onClear]). The subtitle re-evaluates [describe] +/// after either. +class _StorageTile extends StatefulWidget { + final IconData icon; + final String title; + final Future Function() describe; + final Future Function(BuildContext context)? onOpen; + final String? clearTitle; + final String? clearContent; + final Future Function()? onClear; + + const _StorageTile({ + required this.icon, + required this.title, + required this.describe, + this.onOpen, + this.clearTitle, + this.clearContent, + this.onClear, + }); + + @override + State<_StorageTile> createState() => _StorageTileState(); +} + +class _StorageTileState extends State<_StorageTile> { + late Future _description = widget.describe(); + + void _refresh() => setState(() { + _description = widget.describe(); + }); + + @override + Widget build(BuildContext context) { + final onOpen = widget.onOpen; + final onClear = widget.onClear; + return ListTile( + leading: CenteredLeading(Icon(widget.icon)), + title: Text(widget.title), + subtitle: FutureBuilder( + future: _description, + builder: (context, snapshot) => + Text(snapshot.hasError ? '?' : snapshot.data ?? '…'), + ), + onTap: onOpen == null + ? null + : () async { + await onOpen(context); + if (mounted) _refresh(); + }, + trailing: onClear == null + ? const Icon(Icons.arrow_right) + : IconButton( + tooltip: 'Löschen', + icon: const Icon(Icons.delete_outline), + onPressed: () => ConfirmDialog( + title: widget.clearTitle ?? '${widget.title} löschen', + content: widget.clearContent ?? '', + confirmButton: 'Löschen', + errorBuilder: (e) => '$e', + onConfirmAsync: () async { + await onClear(); + if (mounted) _refresh(); + }, + ).asDialog(context), + ), + ); + } } /// Bottom-sheet editor for the backend-independent emergency-notice override @@ -202,7 +348,7 @@ class _EmergencyNoticeUrlEditor extends StatefulWidget { static void show(BuildContext context, SettingsCubit settings) { showDetailsBottomSheet( context, - header: const ListTile(title: Text('Notfall-Nachricht (Quelle)')), + header: const ListTile(title: Text('Notfall-Nachrichtenquelle')), children: (sheetCtx) => [_EmergencyNoticeUrlEditor(settings: settings)], ); } @@ -233,8 +379,7 @@ class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> { void _save() { final raw = _controller.text.trim(); // Empty is valid: it clears the override and falls back to the default. - if (raw.isNotEmpty && - DevToolsSettings.sanitizeCustomUrl(raw) == null) { + if (raw.isNotEmpty && DevToolsSettings.sanitizeCustomUrl(raw) == null) { setState( () => _error = DevToolsSettings.allowsHttpCustomEndpoint ? 'Ungültige URL (http(s)://host[:port]/...)' @@ -253,19 +398,11 @@ class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Padding( - padding: EdgeInsets.only(bottom: 12), - child: Text( - 'Überschreibt die im Code hinterlegte Standardquelle. Leer ' - 'lassen, um die Standardquelle zu verwenden.', - style: TextStyle(fontSize: 12), - ), - ), TextField( controller: _controller, keyboardType: TextInputType.url, decoration: InputDecoration( - labelText: 'https://...', + labelText: 'Leer für Standard', errorText: _error, ), onChanged: (_) { diff --git a/lib/view/pages/settings/sections/notifications_section.dart b/lib/view/pages/settings/sections/notifications_section.dart index cbe1ca0..c81caab 100644 --- a/lib/view/pages/settings/sections/notifications_section.dart +++ b/lib/view/pages/settings/sections/notifications_section.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import '../../../../push/push_registration.dart'; import '../../../../push/push_status.dart'; import '../../../../session/session_manager.dart'; @@ -104,6 +105,8 @@ class _PushSettingsState extends State<_PushSettings> void _onToggle(bool enabled) { widget.settings.val(write: true).notificationSettings.enabled = enabled; + // A permission granted below arrives via the resume heartbeat. + TelemetryHeartbeat.report(notificationsEnabled: enabled); // Turning off does NOT unregister: the device stays subscribed so silent // sync pushes keep arriving; the message handler and iOS NSE suppress only // the visible notification (via the mirrored flag). Enabling (re-)registers @@ -113,11 +116,10 @@ class _PushSettingsState extends State<_PushSettings> final messenger = ScaffoldMessenger.of(context); unawaited(() async { try { - // Only register when the OS permission isn't explicitly denied — - // otherwise NC + proxy would push into the void. - if (await PushRegistration.requestOsPermission()) { - await PushRegistration().register(); - } else { + final granted = await PushRegistration.requestOsPermission(); + // Registered regardless: silent sync pushes need no permission. + await PushRegistration().register(); + if (!granted) { messenger.showSnackBar( const SnackBar( content: Text( diff --git a/lib/view/pages/settings/widgets/settings_dropdown_tile.dart b/lib/view/pages/settings/widgets/settings_dropdown_tile.dart index 8500856..586219a 100644 --- a/lib/view/pages/settings/widgets/settings_dropdown_tile.dart +++ b/lib/view/pages/settings/widgets/settings_dropdown_tile.dart @@ -32,18 +32,21 @@ class SettingsDropdownTile extends StatelessWidget { trailing: DropdownButton( value: value, icon: const Icon(Icons.arrow_drop_down), + alignment: AlignmentDirectional.centerEnd, + selectedItemBuilder: (context) => options + .map( + (e) => Align( + alignment: AlignmentDirectional.centerEnd, + child: _optionRow(e), + ), + ) + .toList(), items: options .map( (e) => DropdownMenuItem( value: e, enabled: e != value, - child: Row( - children: [ - Icon(optionIcon(e)), - const SizedBox(width: 10), - Text(optionLabel(e)), - ], - ), + child: _optionRow(e), ), ) .toList(), @@ -53,4 +56,19 @@ class SettingsDropdownTile extends StatelessWidget { ), ); } + + Widget _optionRow(T e) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(optionIcon(e)), + const SizedBox(width: 10), + Flexible( + child: Text( + optionLabel(e), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); } diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart index 905ebd9..10882a0 100644 --- a/lib/view/pages/share_intent/share_chat_picker.dart +++ b/lib/view/pages/share_intent/share_chat_picker.dart @@ -135,19 +135,24 @@ Future _externalShareFlow( PendingShare share, ) async { if (share.hasFiles) { - await ensureTalkShareFolder(); - if (!context.mounted) return; - await pushScreen( - context, - withNavBar: false, - screen: FilesUploadDialog( - filePaths: share.filePaths, - remotePath: talkShareFolder, - uniqueNames: true, - onUploadFinished: (uploaded) => - _afterExternalFilesUploaded(context, room, uploaded, share), - ), - ); + ShareIntentListener.instance.beginFlow(); + try { + await ensureTalkShareFolder(); + if (!context.mounted) return; + await pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: share.filePaths, + remotePath: talkShareFolder, + uniqueNames: true, + onUploadFinished: (uploaded) => + _afterExternalFilesUploaded(context, room, uploaded, share), + ), + ); + } finally { + ShareIntentListener.instance.endFlow(); + } return; } if (share.hasText) { @@ -155,17 +160,28 @@ Future _externalShareFlow( } } +// Called synchronously when the upload dialog pops, so the flow is entered +// before the upload step above releases it. Future _afterExternalFilesUploaded( BuildContext context, GetRoomResponseObject room, List uploadedRemotePaths, PendingShare share, -) => _runShareFlow( - context, - action: () => - shareFilesToChat(token: room.token, remoteFilePaths: uploadedRemotePaths), - onSuccess: () => _setExternalDraftAndOpenChat(context, room, share), -); +) async { + ShareIntentListener.instance.beginFlow(); + try { + await _runShareFlow( + context, + action: () => shareFilesToChat( + token: room.token, + remoteFilePaths: uploadedRemotePaths, + ), + onSuccess: () => _setExternalDraftAndOpenChat(context, room, share), + ); + } finally { + ShareIntentListener.instance.endFlow(); + } +} /// Shared share-flow scaffolding: shows the blocking spinner, runs [action], /// maps failures to an error dialog (popping the spinner first), and invokes diff --git a/lib/view/pages/share_intent/share_folder_picker.dart b/lib/view/pages/share_intent/share_folder_picker.dart index 1c94522..e6f5fc9 100644 --- a/lib/view/pages/share_intent/share_folder_picker.dart +++ b/lib/view/pages/share_intent/share_folder_picker.dart @@ -206,16 +206,21 @@ Future _externalUploadFlow( List targetPath, PendingShare share, ) async { - await pushScreen( - context, - withNavBar: false, - screen: FilesUploadDialog( - filePaths: share.filePaths, - remotePath: targetPath.join('/'), - onUploadFinished: (_) => - _afterExternalUploaded(context, targetPath, share), - ), - ); + ShareIntentListener.instance.beginFlow(); + try { + await pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: share.filePaths, + remotePath: targetPath.join('/'), + onUploadFinished: (_) => + _afterExternalUploaded(context, targetPath, share), + ), + ); + } finally { + ShareIntentListener.instance.endFlow(); + } } void _afterExternalUploaded( diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 4c1567c..67d1ff0 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -75,6 +75,17 @@ class _ChatListViewState extends State<_ChatListView> { @override Widget build(BuildContext context) { final bloc = context.read(); + // Selected here, not in the LoadableStateConsumer child: that closure runs + // during the consumer's build, where this context may not subscribe. + // Draft keys are part of the selection so the draft marker follows; the + // draft text itself is saved without notifying. + final (favoritesToTop, unreadToTop, _) = context.select( + (SettingsCubit c) => ( + c.state.talkSettings.sortFavoritesToTop, + c.state.talkSettings.sortUnreadToTop, + c.state.talkSettings.drafts.keys.join('|'), + ), + ); return SplitView.material( placeholder: const SplitViewPlaceholder(), breakpoint: 1000, @@ -129,13 +140,9 @@ class _ChatListViewState extends State<_ChatListView> { final rooms = state.rooms; if (rooms == null) return const SizedBox.shrink(); - final talkSettings = context - .watch() - .val() - .talkSettings; final sorted = rooms.sortBy( - favoritesToTop: talkSettings.sortFavoritesToTop, - unreadToTop: talkSettings.sortUnreadToTop, + favoritesToTop: favoritesToTop, + unreadToTop: unreadToTop, ); if (sorted.isEmpty) { @@ -145,23 +152,25 @@ class _ChatListViewState extends State<_ChatListView> { ); } - return ListView( + final drafts = _settings.val().talkSettings.drafts; + final indexByToken = { + for (var i = 0; i < sorted.length; i++) sorted[i].token: i, + }; + return ListView.builder( padding: EdgeInsets.zero, - children: sorted.map((room) { - final hasDraft = _settings - .val() - .talkSettings - .drafts - .containsKey(room.token); - // Stable key keeps element identity across re-sorts so the - // inner UserAvatar reuses its cached bytes instead of - // flashing on every list update. + itemCount: sorted.length, + // Keeps each tile's state (cached avatar bytes) with its room + // when a re-sort moves it to another index. + findChildIndexCallback: (key) => + indexByToken[(key as ValueKey).value], + itemBuilder: (context, index) { + final room = sorted[index]; return ChatTile( key: ValueKey(room.token), data: room, - hasDraft: hasDraft, + hasDraft: drafts.containsKey(room.token), ); - }).toList(), + }, ); }, ), diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index f3a1ba9..6005875 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -14,6 +14,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../../../utils/debouncer.dart'; import '../../../widget/chat_background.dart'; import '../../../widget/clickable_app_bar.dart'; import '../../../widget/user_avatar.dart'; @@ -118,6 +119,7 @@ class _ChatViewState extends State with RouteAware { _markAsReadFinal(); _chatBlocRef?.leaveChat(widget.room.token); _searchTextController.dispose(); + Debouncer.cancel(_searchDebounceTag); super.dispose(); } @@ -150,6 +152,45 @@ class _ChatViewState extends State with RouteAware { context.read().setToken(widget.room.token); } + void _refetch({bool renew = false}) => _refresh(); + + // The built rows only depend on the chat data and the search state; the list + // itself rebuilds far more often (loading flips, keyboard, parent rebuilds), + // and re-sorting plus re-creating every bubble each time is wasted work. + Object? _itemsKey; + List _items = const []; + + List _itemsFor(ChatState state) { + final key = ( + state.chatResponse, + state.isLoadingOlder, + state.hasMoreOld, + _searchActive, + _searchQuery, + _activeMatchIndex, + widget.room, + ); + if (key == _itemsKey) return _items; + _itemsKey = key; + final items = _buildMessages(state.chatResponse!).reversed.toList(); + // reverse:true renders index 0 at the bottom, so the top marker + // (spinner / start-of-chat) goes at the end. + if (state.isLoadingOlder) { + items.add(const _LoadingOlderIndicator()); + } else if (!state.hasMoreOld) { + items.add( + ChatBubble( + key: const ValueKey('chat-start'), + isSender: false, + bubbleData: GetChatResponseObject.getTextDummy('Anfang des Chats'), + chatData: widget.room, + refetch: _refetch, + ), + ); + } + return _items = items; + } + void _enterSearchMode() { setState(() { _searchActive = true; @@ -163,6 +204,7 @@ class _ChatViewState extends State with RouteAware { } void _exitSearchMode() { + Debouncer.cancel(_searchDebounceTag); setState(() { _searchActive = false; _searchQuery = ''; @@ -175,7 +217,22 @@ class _ChatViewState extends State with RouteAware { }); } + late final String _searchDebounceTag = + 'chat-search-${identityHashCode(this)}'; + + /// Matching re-scans the whole history and rebuilds every row, so it runs + /// once typing pauses instead of per keystroke. void _onSearchChanged(String q) { + Debouncer.debounce( + _searchDebounceTag, + const Duration(milliseconds: 200), + () { + if (mounted) _applySearch(q); + }, + ); + } + + void _applySearch(String q) { final chatResponse = context.read().state.data?.chatResponse; setState(() { _searchQuery = q; @@ -268,9 +325,13 @@ class _ChatViewState extends State with RouteAware { messages.add( ChatBubble( isSender: false, + key: ValueKey( + 'day-${elementDate.year}-${elementDate.month}-' + '${elementDate.day}', + ), bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), + refetch: _refetch, ), ); } @@ -286,6 +347,7 @@ class _ChatViewState extends State with RouteAware { messages.add( ChatBubble( + key: ValueKey(element.id), isSender: element.actorId == widget.selfId && (element.messageType == @@ -294,7 +356,7 @@ class _ChatViewState extends State with RouteAware { GetRoomResponseObjectMessageType.deletedComment), bubbleData: element, chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), + refetch: _refetch, isRead: element.id <= commonRead, selfId: widget.selfId, highlightQuery: highlightQuery, @@ -317,17 +379,7 @@ class _ChatViewState extends State with RouteAware { @override Widget build(BuildContext context) { - // Swallow the first back gesture while the keyboard is visible so it - // dismisses the IME instead of popping the chat — matches platform UX - // expectations (e.g. WhatsApp/Telegram) and prevents accidental exits - // mid-typing. - final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; - return PopScope( - canPop: !keyboardOpen, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; - FocusManager.instance.primaryFocus?.unfocus(); - }, + return _KeyboardDismissPopScope( child: Scaffold( backgroundColor: const Color(0xffefeae2), appBar: _searchActive @@ -376,25 +428,7 @@ class _ChatViewState extends State with RouteAware { state.currentToken == widget.room.token, enablePullToRefresh: false, child: (state, _) { - final items = _buildMessages( - state.chatResponse!, - ).reversed.toList(); - // reverse:true renders index 0 at the bottom, so the top - // marker (spinner / start-of-chat) goes at the end. - if (state.isLoadingOlder) { - items.add(const _LoadingOlderIndicator()); - } else if (!state.hasMoreOld) { - items.add( - ChatBubble( - isSender: false, - bubbleData: GetChatResponseObject.getTextDummy( - 'Anfang des Chats', - ), - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - ), - ); - } + final items = _itemsFor(state); _itemCount = items.length; return ScrollablePositionedList.builder( reverse: true, @@ -448,3 +482,27 @@ class _LoadingOlderIndicator extends StatelessWidget { ), ); } + +/// Swallows the first back gesture while the keyboard is visible so it +/// dismisses the IME instead of popping the chat — matches platform UX +/// expectations (e.g. WhatsApp/Telegram) and prevents accidental exits +/// mid-typing. A separate widget so the per-frame inset changes during the +/// keyboard animation only rebuild this scope, not the whole chat. +class _KeyboardDismissPopScope extends StatelessWidget { + final Widget child; + + const _KeyboardDismissPopScope({required this.child}); + + @override + Widget build(BuildContext context) { + final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; + return PopScope( + canPop: !keyboardOpen, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + FocusManager.instance.primaryFocus?.unfocus(); + }, + child: child, + ); + } +} diff --git a/lib/view/pages/talk/details/chat_info.dart b/lib/view/pages/talk/details/chat_info.dart index 58a2880..5f07346 100644 --- a/lib/view/pages/talk/details/chat_info.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -6,6 +6,7 @@ import '../../../../api/marianumcloud/talk/get_participants/get_participants_cac import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../api/marianumcloud/talk/room/notification_level.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; @@ -14,6 +15,7 @@ import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/large_profile_picture_view.dart'; import '../../../../widget/user_avatar.dart'; import '../talk_navigator.dart'; +import '../widgets/notification_level_sheet.dart'; import 'participants_list_view.dart'; import 'shared_items_view.dart'; @@ -29,6 +31,7 @@ class _ChatInfoState extends State { GetParticipantsResponse? participants; GetSharedItemsOverviewResponse? _sharesOverview; late bool _isFavorite; + late NotificationLevel _notificationLevel; int _avatarVersion = 0; bool _avatarBusy = false; @@ -36,6 +39,7 @@ class _ChatInfoState extends State { void initState() { super.initState(); _isFavorite = widget.room.isFavorite; + _notificationLevel = widget.room.effectiveNotificationLevel; GetParticipantsCache( chatToken: widget.room.token, onUpdate: (GetParticipantsResponse data) { @@ -67,6 +71,16 @@ class _ChatInfoState extends State { _refreshList(); } + Future _editNotificationLevel() async { + final level = await showNotificationLevelSheet( + context, + widget.room, + current: _notificationLevel, + ); + if (level == null || !mounted) return; + setState(() => _notificationLevel = level); + } + Future _confirmLeave() async { final closed = await showDialog( context: context, @@ -243,6 +257,11 @@ class _ChatInfoState extends State { title: const Text('Zu Favoriten hinzufügen'), onPressed: _toggleFavorite, ), + NotificationLevelTile( + level: _notificationLevel, + trailing: const Icon(Icons.arrow_right), + onTap: _editNotificationLevel, + ), const Divider(), ListTile( leading: Icon( diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 936e800..cb58ecf 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -59,6 +59,13 @@ class _ChatBubbleState extends State with SingleTickerProviderStateMixin, DownloadTrigger { late ChatMessage message; + // The parsed message and its widget are only rebuilt when their inputs + // change. The bubble itself rebuilds on every chat emit, swipe frame and + // keyboard frame; re-running rich-object parsing, linkify/Markdown and emoji + // detection each time is what makes long chats stutter. + Object? _messageKey; + late Widget _messageWidget; + Offset _position = Offset.zero; Offset _dragStartPosition = Offset.zero; bool _swipeActionArmed = false; @@ -185,12 +192,34 @@ class _ChatBubbleState extends State } } - @override - Widget build(BuildContext context) { + void _updateMessage(BuildContext context) { + final style = _messageTextStyle(context); + final renderMarkdown = + widget.bubbleData.markdown && + widget.bubbleData.messageType == + GetRoomResponseObjectMessageType.comment; + final key = ( + widget.bubbleData, + widget.highlightQuery, + style, + renderMarkdown, + ); + if (key == _messageKey) return; + _messageKey = key; message = ChatMessage( originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters, ); + _messageWidget = message.getWidget( + highlightQuery: widget.highlightQuery, + style: style, + renderMarkdown: renderMarkdown, + ); + } + + @override + Widget build(BuildContext context) { + _updateMessage(context); final showActorDisplayName = _rendersAsCommentBubble && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; @@ -277,14 +306,7 @@ class _ChatBubbleState extends State actorText: actorText, actorWidget: actorWidget, timeText: timeText, - messageWidget: message.getWidget( - highlightQuery: widget.highlightQuery, - style: _messageTextStyle(context), - renderMarkdown: - widget.bubbleData.markdown && - widget.bubbleData.messageType == - GetRoomResponseObjectMessageType.comment, - ), + messageWidget: _messageWidget, parent: parent, bubbleData: widget.bubbleData, isSender: widget.isSender, @@ -350,10 +372,12 @@ class _BubbleContent extends StatelessWidget { Widget build(BuildContext context) => MergeSemantics( child: Container( constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.9, + maxWidth: MediaQuery.sizeOf(context).width * 0.9, minWidth: showActorDisplayName - ? actorText.size.width - : timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, + ? actorText.measuredWidth(context) + : timeText.measuredWidth(context) + + (isSender ? spacing + timeIconSize : 0) + + 3, ), child: Stack( children: [ diff --git a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart index e7bd55c..9583670 100644 --- a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart +++ b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart @@ -34,7 +34,7 @@ class ChatBubbleReactions extends StatelessWidget { return Transform.translate( offset: const Offset(0, -10), child: Container( - width: MediaQuery.of(context).size.width, + width: MediaQuery.sizeOf(context).width, margin: const EdgeInsets.only(left: 15, right: 15), child: Wrap( alignment: isSender ? WrapAlignment.end : WrapAlignment.start, diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 7769b5a..2ef9438 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -83,12 +83,20 @@ class _ChatTextfieldState extends State { ); } + /// Called per keystroke, so the text itself is saved silently; only a draft + /// appearing or disappearing notifies listeners (the chat list's marker). void _setDraft(String text) { - final talkSettings = settings.val(write: true).talkSettings; + final drafts = settings.val().talkSettings.drafts; + final hadDraft = drafts.containsKey(widget.sendToToken); if (text.isNotEmpty) { - talkSettings.drafts[widget.sendToToken] = text; + drafts[widget.sendToToken] = text; } else { - talkSettings.drafts.removeWhere((key, _) => key == widget.sendToToken); + drafts.remove(widget.sendToToken); + } + if (hadDraft != text.isNotEmpty) { + settings.val(write: true); + } else { + settings.saveSilently(); } } @@ -276,17 +284,22 @@ class _ChatTextfieldState extends State { @override Widget build(BuildContext context) { - final chatBloc = context.watch(); - final chatState = chatBloc.state.data; + final chatBloc = context.read(); + // Only the reply reference is rendered from the chat state; loading flags + // and paging emits don't need to rebuild the input. + final (referenceMessageId, chatResponse) = context.select( + (ChatBloc b) => ( + b.state.data?.referenceMessageId, + b.state.data?.chatResponse, + ), + ); Widget replyBanner = const SizedBox.shrink(); - if (chatState != null && - chatState.referenceMessageId != null && - chatState.chatResponse != null) { + if (referenceMessageId != null && chatResponse != null) { try { - final referenceMessage = chatState.chatResponse! - .sortByTimestamp() - .firstWhere((e) => e.id == chatState.referenceMessageId); + final referenceMessage = chatResponse.data.firstWhere( + (e) => e.id == referenceMessageId, + ); replyBanner = Row( children: [ Expanded( diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index e409a45..1aac3f5 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../api/marianumcloud/talk/room/notification_level.dart'; import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; import '../../../../extensions/date_time.dart'; import '../../../../notification/notification_tasks.dart'; @@ -22,6 +23,7 @@ import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/user_avatar.dart'; import '../data/talk_markdown.dart'; import '../talk_navigator.dart'; +import 'notification_level_sheet.dart'; class ChatTile extends StatefulWidget { final GetRoomResponseObject data; @@ -62,15 +64,25 @@ class _ChatTileState extends State { /// One-line preview of the last message: rich-object placeholders resolved, /// newlines flattened and — for Markdown messages — formatting stripped so /// the list shows readable text rather than raw markers. + /// + /// Memoised per last message: the tile rebuilds on every chat-list emit and + /// the Markdown strip is a full parse. Keyed by content, since every refresh + /// delivers new message objects. String _lastMessagePreview() { final last = widget.data.lastMessage; + final key = (last.id, last.message, last.markdown); + if (key == _previewFor) return _preview; final text = RichObjectStringProcessor.parseToString( last.message.replaceAll('\n', ' '), last.messageParameters, ); - return last.markdown ? markdownToPlainText(text) : text; + _previewFor = key; + return _preview = last.markdown ? markdownToPlainText(text) : text; } + Object? _previewFor; + String _preview = ''; + Future _setCurrentAsRead() async { final token = widget.data.token; final lastId = widget.data.lastMessage.id; @@ -83,7 +95,11 @@ class _ChatTileState extends State { @override Widget build(BuildContext context) { - final chatBloc = context.watch(); + // Only the open token matters here (split-view highlight); watching the + // whole bloc rebuilt every tile on each message of the open chat. + final currentToken = context.select( + (ChatBloc b) => b.state.data?.currentToken, + ); final isGroup = widget.data.type != GetRoomResponseObjectConversationType.oneToOne; final circleAvatar = UserAvatar( @@ -94,7 +110,7 @@ class _ChatTileState extends State { return ListTile( style: ListTileStyle.list, tileColor: - chatBloc.state.data?.currentToken == widget.data.token && + currentToken == widget.data.token && TalkNavigator.isSecondaryVisible(context) ? Theme.of(context).primaryColor.withAlpha(100) : null, @@ -147,6 +163,15 @@ class _ChatTileState extends State { semanticLabel: A11yLabels.draft, ), ], + if (widget.data.isMuted) ...[ + const SizedBox(width: 5), + Icon( + NotificationLevel.neverNotify.icon, + size: 15, + color: Theme.of(context).colorScheme.onSurfaceVariant, + semanticLabel: A11yLabels.muted, + ), + ], ], ), subtitle: Text( @@ -234,6 +259,17 @@ class _ChatTileState extends State { if (mounted) _refreshList(); }, ), + NotificationLevelTile( + level: widget.data.effectiveNotificationLevel, + onTap: () { + Navigator.of(sheetCtx).pop(); + showNotificationLevelSheet( + context, + widget.data, + current: widget.data.effectiveNotificationLevel, + ); + }, + ), ListTile( leading: const Icon(Icons.delete_outline), title: const Text('Talk-Chat verlassen'), diff --git a/lib/view/pages/talk/widgets/notification_level_sheet.dart b/lib/view/pages/talk/widgets/notification_level_sheet.dart new file mode 100644 index 0000000..a704492 --- /dev/null +++ b/lib/view/pages/talk/widgets/notification_level_sheet.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../api/marianumcloud/talk/room/notification_level.dart'; +import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../../../../widget/async_action_button.dart'; +import '../../../../widget/details_bottom_sheet.dart'; + +extension NotificationLevelPresentation on NotificationLevel { + // defaultLevel never reaches the UI (see effectiveNotificationLevel), but + // the switch has to stay exhaustive. + ({String label, String description, IconData icon}) get _presentation => + switch (this) { + NotificationLevel.alwaysNotify || NotificationLevel.defaultLevel => ( + label: 'Alle Nachrichten', + description: 'Bei jeder neuen Nachricht', + icon: Icons.notifications_outlined, + ), + NotificationLevel.notifyOnMention => ( + label: 'Nur Erwähnungen', + description: 'Nur wenn du erwähnt wirst', + icon: Icons.alternate_email, + ), + NotificationLevel.neverNotify => ( + label: 'Aus', + description: 'Keine Benachrichtigungen', + icon: Icons.notifications_off_outlined, + ), + }; + + String get label => _presentation.label; + String get description => _presentation.description; + IconData get icon => _presentation.icon; +} + +/// Lets the user pick the room's Talk notification level. Nextcloud applies +/// it server-side, so no push for the room is sent at all when muted. +/// Returns the new level, or null when nothing changed. +Future showNotificationLevelSheet( + BuildContext context, + GetRoomResponseObject room, { + required NotificationLevel current, +}) async { + final bloc = context.read(); + NotificationLevel? selected; + await showDetailsBottomSheet( + context, + header: ListTile( + leading: const Icon(Icons.notifications_outlined), + title: const Text('Benachrichtigungen'), + subtitle: Text(room.displayName, overflow: TextOverflow.ellipsis), + ), + children: (_) => [ + for (final level in selectableNotificationLevels) + AsyncListTile( + leading: Icon( + level == current + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + title: Text(level.label), + subtitle: Text(level.description), + onPressed: () async { + if (level == current) return; + await bloc.setNotificationLevel(room.token, level); + selected = level; + }, + ), + ], + ); + return selected; +} + +class NotificationLevelTile extends StatelessWidget { + final NotificationLevel level; + final VoidCallback onTap; + final Widget? trailing; + + const NotificationLevelTile({ + required this.level, + required this.onTap, + this.trailing, + super.key, + }); + + @override + Widget build(BuildContext context) => ListTile( + leading: Icon(level.icon), + title: const Text('Benachrichtigungen'), + subtitle: Text(level.label), + trailing: trailing, + onTap: onTap, + ); +} diff --git a/lib/view/pages/timetable/data/calendar_logic.dart b/lib/view/pages/timetable/data/calendar_logic.dart index 56def74..54b9dd7 100644 --- a/lib/view/pages/timetable/data/calendar_logic.dart +++ b/lib/view/pages/timetable/data/calendar_logic.dart @@ -1,7 +1,7 @@ -import 'package:rrule/rrule.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../extensions/date_time.dart'; +import '../../../../utils/recurrence_occurrences.dart'; import 'arbitrary_appointment.dart'; import 'calendar_layout.dart'; import 'lesson_period_schedule.dart'; @@ -103,7 +103,6 @@ partitionAppointmentsForWeek( continue; } try { - final parsed = RecurrenceRule.fromString(rule); final anchorUtc = a.startTime.toUtc(); final duration = a.endTime.difference(a.startTime); // Day-keyed set of exception dates so occurrences scheduled for one @@ -112,9 +111,12 @@ partitionAppointmentsForWeek( final exceptionDayKeys = (a.recurrenceExceptionDates ?? const []) .map((d) => '${d.year}-${d.month}-${d.day}') .toSet(); - for (final occUtc in parsed.getInstances(start: anchorUtc)) { - if (!occUtc.isBefore(weekEndUtc)) break; - if (occUtc.isBefore(weekStartUtc)) continue; + for (final occUtc in RecurrenceOccurrences.between( + rule, + anchorUtc, + weekStartUtc, + weekEndUtc, + )) { final occLocal = occUtc.toLocal(); if (exceptionDayKeys.contains( '${occLocal.year}-${occLocal.month}-${occLocal.day}', diff --git a/lib/view/pages/timetable/data/lesson_merger.dart b/lib/view/pages/timetable/data/lesson_merger.dart index bb5ab06..03f1e28 100644 --- a/lib/view/pages/timetable/data/lesson_merger.dart +++ b/lib/view/pages/timetable/data/lesson_merger.dart @@ -73,5 +73,6 @@ class LessonMerger { substitutionText: source.substitutionText, lessonText: source.lessonText, infoText: source.infoText, + classIds: source.classIds, ); } diff --git a/lib/view/pages/timetable/data/lesson_type_label.dart b/lib/view/pages/timetable/data/lesson_type_label.dart index e5aa2c2..3b4f265 100644 --- a/lib/view/pages/timetable/data/lesson_type_label.dart +++ b/lib/view/pages/timetable/data/lesson_type_label.dart @@ -1,4 +1,5 @@ import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import 'lesson_labels.dart'; /// Derives a human-readable title from the Marianum-Connect `lessonType` for /// lessons that have no subject of their own (Aufsicht, Sprechstunde, …). @@ -10,10 +11,20 @@ class LessonTypeLabel { static String forEntry(McTimetableEntry lesson) { final base = baseLabel(lesson.lessonType); + if (base == fallback) { + final text = entryText(lesson); + if (text != null) return text; + } final room = (lesson.rooms.firstOrNull ?? '').trim(); return room.isEmpty ? base : '$room $base'; } + /// Webuntis-Freitext eines fachlosen Eintrags („Klassenfahrt 8a“), der + /// aussagekräftiger ist als das generische „Event“. + static String? entryText(McTimetableEntry lesson) => + collapseWhitespace(lesson.substitutionText) ?? + collapseWhitespace(lesson.lessonText); + /// Returns just the type wording without the room — useful when the caller /// renders the room separately (e.g. as a subtitle line). static String baseLabel(String lessonType) { diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 307ef28..0f9fa69 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -213,10 +213,12 @@ class TimetableAppointmentFactory { return LessonColor.forStatus(status); } - McSubject? _findSubject(String? subjectShort) { - if (subjectShort == null) return null; - return subjects.where((s) => s.shortName == subjectShort).firstOrNull; - } + late final Map _subjectsByShort = { + for (final s in subjects.reversed) s.shortName: s, + }; + + McSubject? _findSubject(String? subjectShort) => + subjectShort == null ? null : _subjectsByShort[subjectShort]; String _subjectName(String? subjectShort, McTimetableEntry lesson) { if (subjectShort != null) { @@ -240,12 +242,14 @@ class TimetableAppointmentFactory { String _locationLabel(McTimetableEntry lesson) { final roomName = collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt'; - // Klassenlose Einträge (Aufsichten etc.) fallen auf den Lehrer zurück. - final secondLine = - (showClassInsteadOfTeacher ? lesson.classLabel : null) ?? - _teacherLabel(lesson.teachers.firstOrNull) ?? - 'Unbekannt'; - return '$roomName\n$secondLine'; + if (showClassInsteadOfTeacher) { + // Klassenlose Einträge (Aufsichten etc.) zeigen nur den Raum — die + // Lehrkraft wäre hier die Plan-Inhaberin selbst. + final classLabel = lesson.classLabel; + return classLabel == null ? roomName : '$roomName\n$classLabel'; + } + final teacher = _teacherLabel(lesson.teachers.firstOrNull) ?? 'Unbekannt'; + return '$roomName\n$teacher'; } /// Backend serves teachers with their full display name ("Stefan Müller"), diff --git a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart index de8edd0..c02cc08 100644 --- a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart +++ b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; +import '../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../data/arbitrary_appointment.dart'; import 'custom_event_sheet.dart'; @@ -12,6 +13,8 @@ class AppointmentDetailsDispatcher { TimetableState? state, Appointment appointment, { bool canEditSubjectColor = false, + bool teacherPlan = false, + void Function(TimetableElementRef element)? onOpenElement, }) { final id = appointment.id; if (id is! ArbitraryAppointment) return; @@ -23,6 +26,8 @@ class AppointmentDetailsDispatcher { appointment, entry, canEditSubjectColor: canEditSubjectColor, + teacherPlan: teacherPlan, + onOpenElement: onOpenElement, ), custom: (event) => CustomEventSheet.show(context, event), ); diff --git a/lib/view/pages/timetable/details/lesson_sheet.dart b/lib/view/pages/timetable/details/lesson_sheet.dart index 5f0d5f4..d402d04 100644 --- a/lib/view/pages/timetable/details/lesson_sheet.dart +++ b/lib/view/pages/timetable/details/lesson_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; +import '../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../extensions/date_time.dart'; import '../../../../extensions/text.dart'; @@ -10,6 +11,7 @@ import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/details_bottom_sheet.dart'; +import '../data/lesson_labels.dart'; import '../data/lesson_type_label.dart'; import '../subject_colors/subject_color_picker.dart'; @@ -20,6 +22,8 @@ class LessonSheet { Appointment appointment, McTimetableEntry lesson, { bool canEditSubjectColor = false, + bool teacherPlan = false, + void Function(TimetableElementRef element)? onOpenElement, }) { if (state == null) return; @@ -71,7 +75,7 @@ class LessonSheet { ) : null, ), - children: (_) => [ + children: (sheetContext) => [ ListTile( leading: const Icon(Icons.notifications_active), title: Text('Status: ${_statusLabel(lesson.status)}'), @@ -87,13 +91,14 @@ class LessonSheet { .toList(), ), _roomTile(context, lesson), - _teacherTile(context, lesson), - if (lesson.classNames.isNotEmpty) - _listTile( - icon: Icons.people, - label: lesson.classNames.length == 1 ? 'Klasse' : 'Klassen', - entries: lesson.classNames.map(_line).toList(), - ), + ..._peopleTiles( + context, + lesson, + opener: onOpenElement == null + ? null + : _ElementOpener(sheetContext, onOpenElement), + teacherPlan: teacherPlan, + ), ..._optionalTextTiles(lesson), DebugTile(context).jsonData(lesson.toJson()), ], @@ -127,7 +132,59 @@ class LessonSheet { ); } - static Widget _teacherTile(BuildContext context, McTimetableEntry lesson) { + // Aus Lehrersicht ist die Klasse die relevante Angabe, die Lehrkraft ist + // meist die Plan-Inhaberin selbst — daher dort Klasse zuerst. + static List _peopleTiles( + BuildContext context, + McTimetableEntry lesson, { + required _ElementOpener? opener, + required bool teacherPlan, + }) { + final classTile = _classTile(lesson, opener); + final teacherTile = _teacherTile( + context, + lesson, + opener, + teacherPlan: teacherPlan, + ); + return teacherPlan ? [?classTile, teacherTile] : [teacherTile, ?classTile]; + } + + static Widget? _classTile(McTimetableEntry lesson, _ElementOpener? opener) { + final names = lesson.classNames; + if (names.isEmpty) return null; + const tooltip = 'Stundenplan der Klasse öffnen'; + final ids = lesson.classIds?.length == names.length + ? lesson.classIds + : null; + TimetableElementRef? element(int i) => ids == null + ? null + : (type: TimetableElementType.schoolClass, id: ids[i], label: names[i]); + + return _listTile( + icon: Icons.people, + label: names.length == 1 ? 'Klasse' : 'Klassen', + entries: names.map(_line).toList(), + trailing: names.length == 1 + ? opener?.button(tooltip: tooltip, element: element(0)) + : null, + wrapRow: (i, row) => _openable(opener, row, tooltip, element(i)), + ); + } + + static Widget _openable( + _ElementOpener? opener, + Widget label, + String tooltip, + TimetableElementRef? element, + ) => opener?.row(label: label, tooltip: tooltip, element: element) ?? label; + + static Widget _teacherTile( + BuildContext context, + McTimetableEntry lesson, + _ElementOpener? opener, { + required bool teacherPlan, + }) { if (lesson.teachers.isEmpty) { return const ListTile( leading: Icon(Icons.person), @@ -145,12 +202,17 @@ class LessonSheet { } final label = teachers.length == 1 ? 'Lehrkraft' : 'Lehrkräfte'; + const tooltip = 'Stundenplan der Lehrkraft öffnen'; // Einzelne, reguläre Lehrkraft kompakt in der Titelzeile. if (teachers.length == 1 && teachers.first.isPlain) { return ListTile( leading: const Icon(Icons.person), title: Text('$label: ${teachers.first.after}'), + // Im Lehrerplan ist das die Plan-Inhaberin selbst. + trailing: teacherPlan + ? null + : opener?.button(tooltip: tooltip, element: teachers.first.target), ); } @@ -159,7 +221,10 @@ class LessonSheet { title: Text(label), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [for (final t in teachers) t.buildRow(context)], + children: [ + for (final t in teachers) + _openable(opener, t.buildRow(context), tooltip, t.target), + ], ), ); } @@ -206,6 +271,7 @@ class LessonSheet { required String label, required List entries, Widget? trailing, + Widget Function(int index, Widget row)? wrapRow, }) { if (entries.length == 1) { return ListTile( @@ -219,17 +285,23 @@ class LessonSheet { title: Text(label), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: entries.map(Text.new).toList(), + children: [ + for (final (i, entry) in entries.indexed) + wrapRow?.call(i, Text(entry)) ?? Text(entry), + ], ), trailing: trailing, ); } static List _optionalTextTiles(McTimetableEntry lesson) { + final sameText = + collapseWhitespace(lesson.lessonText) == + collapseWhitespace(lesson.substitutionText); return [ _textTile(Icons.info_outline, 'Info', lesson.infoText), _textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substitutionText), - _textTile(Icons.subject, 'Stundentext', lesson.lessonText), + if (!sameText) _textTile(Icons.subject, 'Stundentext', lesson.lessonText), _textTile( Icons.category_outlined, 'Stundentyp', @@ -329,7 +401,15 @@ class _TeacherDisplay { /// Aktuelle Lehrkraft. Leer bei ersatzlosem Entfall. final String after; - const _TeacherDisplay._({this.before = '', this.after = ''}); + /// Lehrkraft, deren Stundenplan sich öffnen lässt: die aktuelle, bei + /// ersatzlosem Entfall die ursprüngliche. Null, wenn beide unbekannt sind. + final TimetableElementRef? target; + + const _TeacherDisplay._({ + this.before = '', + this.after = '', + this.target, + }); bool get isPlain => before.isEmpty; @@ -341,14 +421,32 @@ class _TeacherDisplay { t.originalShortName ?? '', t.originalDisplayName ?? '', ); + final target = _target(t, currentKnown: current.isNotEmpty); // Kein (abweichendes) Original → reguläre Lehrkraft. if (original.isEmpty || original == current) { - return _TeacherDisplay._(after: current.isEmpty ? '?' : current); + return _TeacherDisplay._( + after: current.isEmpty ? '?' : current, + target: target, + ); } // Original vorhanden → ersetzte/entfallende Lehrkraft vorn, aktuelle // Lehrkraft (falls vorhanden) als Ersatz dahinter. - return _TeacherDisplay._(before: original, after: current); + return _TeacherDisplay._( + before: original, + after: current, + target: target, + ); + } + + static TimetableElementRef? _target( + McTimetableTeacher t, { + required bool currentKnown, + }) { + final id = currentKnown ? t.id : t.originalId; + if (id == null) return null; + final label = currentKnown ? t.displayName : t.originalDisplayName; + return (type: TimetableElementType.teacher, id: id, label: label ?? ''); } /// Eine Bullet-Zeile je Lehrkraft: ersetzte Person durchgestrichen, bei @@ -400,3 +498,58 @@ class _TeacherDisplay { return short; } } + +/// Baut die Bedienelemente, die aus dem Sheet heraus einen fremden +/// Stundenplan öffnen. Ohne Element-Id (älterer Server) entfallen sie. +class _ElementOpener { + static const IconData icon = Icons.calendar_view_week_outlined; + + final BuildContext sheetContext; + final void Function(TimetableElementRef element) onOpen; + + const _ElementOpener(this.sheetContext, this.onOpen); + + void _open(TimetableElementRef element) { + Navigator.of(sheetContext).pop(); + onOpen(element); + } + + /// Trailing-Button für einzeilige Tiles, analog zum Raumplan-Button. + Widget? button({ + required String tooltip, + required TimetableElementRef? element, + }) { + if (element == null) return null; + return IconButton( + icon: const Icon(icon), + tooltip: tooltip, + onPressed: () => _open(element), + ); + } + + /// Kompakte, vollständig antippbare Zeile für Aufzählungen — ein + /// 48px-Button pro Eintrag würde die Liste auseinanderziehen. + Widget row({ + required Widget label, + required String tooltip, + required TimetableElementRef? element, + }) { + if (element == null) return label; + final muted = Theme.of(sheetContext).colorScheme.onSurfaceVariant; + return Semantics( + button: true, + label: tooltip, + child: InkWell( + onTap: () => _open(element), + borderRadius: BorderRadius.circular(6), + child: Row( + children: [ + Expanded(child: label), + const SizedBox(width: 8), + Icon(icon, size: 18, color: muted), + ], + ), + ), + ); + } +} diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 5179fd8..1a6aa60 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -54,6 +54,10 @@ class _TimetableState extends State { if (guardDemoAction(context)) return; final ref = await AppRoutes.openElementPicker(context); if (!mounted || ref == null) return; + _openElement(ref); + } + + void _openElement(TimetableElementRef ref) { setState(() => _selected = ref); } @@ -190,6 +194,8 @@ class _TimetableState extends State { state, apt, canEditSubjectColor: policy.canEditSubjectColors, + teacherPlan: policy.showClassInsteadOfTeacher, + onOpenElement: policy.canOpenForeign ? _openElement : null, ), onCreateEvent: policy.canManageCustomEvents ? _onCreateEventAt @@ -244,11 +250,12 @@ class _ViewingBanner extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final isFavorite = context - .watch() - .val() - .timetableFavoritesSettings - .isFavorite(element.type, element.id); + final isFavorite = context.select( + (SettingsCubit c) => c.state.timetableFavoritesSettings.isFavorite( + element.type, + element.id, + ), + ); final onColor = theme.colorScheme.onSecondaryContainer; // Compact icon button: ~32px square, no extra padding, so the banner stays diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index 0d8c1cf..a5ef8c8 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; @@ -11,16 +12,19 @@ class AppointmentTile extends StatelessWidget { final Appointment appointment; final bool crossedOut; + /// Ticking clock; only the tile background listens, so a lesson fades to + /// the "past" look without rebuilding the text layout. + final ValueListenable now; + const AppointmentTile({ super.key, required this.appointment, + required this.now, this.crossedOut = false, }); @override Widget build(BuildContext context) { - final isPast = appointment.endTime.isBefore(DateTime.now()); - final color = appointment.color.withAlpha(isPast ? 160 : 255); final isCustom = appointment.id is CustomAppointment; final description = appointment.location ?? ''; @@ -29,13 +33,19 @@ class AppointmentTile extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - alignment: Alignment.topLeft, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: _radius, - color: color, + child: ValueListenableBuilder( + valueListenable: now, + builder: (context, now, child) => Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + alignment: Alignment.topLeft, + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: _radius, + color: appointment.color.withAlpha( + appointment.endTime.isBefore(now) ? 160 : 255, + ), + ), + child: child, ), child: _TileContent( title: appointment.subject, @@ -100,8 +110,10 @@ class _TileContent extends StatelessWidget { // entirely; the coloured rectangle is enough. if (available < titleLineHeight) return const SizedBox.shrink(); - final remaining = - (available - titleLineHeight).clamp(0.0, double.infinity); + final remaining = (available - titleLineHeight).clamp( + 0.0, + double.infinity, + ); final bodyLineCapacity = (remaining / bodyLineHeight).floor(); if (isCustom) { @@ -187,7 +199,9 @@ class _AdaptiveTitle extends StatelessWidget { maxLines: 1, textScaler: textScaler, )..layout(); - if (probe.width > constraints.maxWidth) { + final overflows = probe.width > constraints.maxWidth; + probe.dispose(); + if (overflows) { return Text( text, style: baseStyle.copyWith(fontSize: minFontSize), diff --git a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart index 35ba38a..259b1e2 100644 --- a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart +++ b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart @@ -1,16 +1,14 @@ part of '../custom_workweek_calendar.dart'; class _OutsideHoursStrip extends StatelessWidget { - final DateTime weekStart; - final List appointments; + final List> outside; final double rulerWidth; final void Function(Appointment) onAppointmentTap; final bool Function(Appointment) isCrossedOut; const _OutsideHoursStrip({ super.key, - required this.weekStart, - required this.appointments, + required this.outside, required this.rulerWidth, required this.onAppointmentTap, required this.isCrossedOut, @@ -18,10 +16,6 @@ class _OutsideHoursStrip extends StatelessWidget { @override Widget build(BuildContext context) { - final outside = partitionAppointmentsForWeek( - appointments, - weekStart, - ).outside; if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink(); final theme = Theme.of(context); diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart index f46fa61..e2c6e0e 100644 --- a/lib/view/pages/timetable/widgets/calendar/week_grid.dart +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -1,10 +1,31 @@ part of '../custom_workweek_calendar.dart'; +/// Per-week derived data, computed once per appointment/region set instead of +/// on every rebuild of a week page. +class _WeekData { + final List> inside; + final List> outside; + final List> regions; + + _WeekData._(this.inside, this.outside, this.regions); + + factory _WeekData.compute( + DateTime weekStart, + List appointments, + List timeRegions, + ) { + final partitioned = partitionAppointmentsForWeek(appointments, weekStart); + return _WeekData._(partitioned.inside, partitioned.outside, [ + for (var d = 0; d < 5; d++) + expandRegionsForDay(timeRegions, weekStart.addDays(d)), + ]); + } +} + class _WeekGrid extends StatelessWidget { final DateTime weekStart; final LessonPeriodSchedule schedule; - final List appointments; - final List timeRegions; + final _WeekData data; final void Function(Appointment) onAppointmentTap; final bool Function(Appointment) isCrossedOut; final void Function(DateTime start, DateTime end)? onCreateEvent; @@ -16,8 +37,7 @@ class _WeekGrid extends StatelessWidget { const _WeekGrid({ required this.weekStart, required this.schedule, - required this.appointments, - required this.timeRegions, + required this.data, required this.onAppointmentTap, required this.isCrossedOut, required this.onCreateEvent, @@ -29,8 +49,6 @@ class _WeekGrid extends StatelessWidget { @override Widget build(BuildContext context) { - final partitioned = partitionAppointmentsForWeek(appointments, weekStart); - return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -48,8 +66,8 @@ class _WeekGrid extends StatelessWidget { child: _DayColumn( date: weekStart.addDays(d), schedule: schedule, - appointments: partitioned.inside[d], - timeRegions: timeRegions, + appointments: data.inside[d], + regions: data.regions[d], layout: layout, today: today, nowNotifier: nowNotifier, @@ -195,7 +213,7 @@ class _DayColumn extends StatelessWidget { final DateTime date; final LessonPeriodSchedule schedule; final List appointments; - final List timeRegions; + final List regions; final PeriodLayout layout; final DateTime today; final ValueListenable nowNotifier; @@ -207,7 +225,7 @@ class _DayColumn extends StatelessWidget { required this.date, required this.schedule, required this.appointments, - required this.timeRegions, + required this.regions, required this.layout, required this.today, required this.nowNotifier, @@ -280,12 +298,10 @@ class _DayColumn extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - final dayAppointments = appointments; - final dayRegions = expandRegionsForDay(timeRegions, date); final isToday = date.isSameDay(today); - final isTablet = MediaQuery.of(context).size.shortestSide >= 600; - final laidOut = assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2); + final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600; + final laidOut = assignLanes(appointments, maxLanes: isTablet ? 3 : 2); final dayName = DateFormat( 'EEEE', @@ -304,8 +320,7 @@ class _DayColumn extends StatelessWidget { sortKey: OrdinalSortKey(date.weekday.toDouble()), child: GestureDetector( behavior: HitTestBehavior.translucent, - onLongPressStart: (details) => - _handleLongPress(details, dayAppointments), + onLongPressStart: (details) => _handleLongPress(details, appointments), child: DecoratedBox( decoration: BoxDecoration( color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, @@ -342,7 +357,7 @@ class _DayColumn extends StatelessWidget { color: theme.dividerColor.withAlpha(60), ), ), - for (final region in dayRegions) + for (final region in regions) Positioned( top: layout.yOfDateTime(region.start), height: @@ -379,6 +394,7 @@ class _DayColumn extends StatelessWidget { onTap: () => onAppointmentTap(appointment), child: AppointmentTile( appointment: appointment, + now: nowNotifier, crossedOut: isCrossedOut(appointment), ), ), diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart index 690a749..6f4f108 100644 --- a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -61,29 +61,41 @@ class CustomWorkWeekCalendarState extends State { static const double _rulerWidth = 36; late PageController _pageController; - late int _currentWeekIndex; + // Drives header + all-day strip. Kept out of setState so a page change + // mid-swipe doesn't rebuild the PageView and every tile in it. + late final ValueNotifier _visibleWeekIndex; + late int _reportedWeekIndex; late DateTime _firstMonday; late int _totalWeeks; late Timer _ticker; late ValueNotifier _nowNotifier; - DateTime _today = _dateOnly(DateTime.now()); + DateTime _today = DateTime.now().dateOnly; + + final Map _weekData = {}; + // Returning the identical widget instance lets Flutter skip rebuilding a + // week page when the host rebuilds for unrelated reasons (e.g. the bloc + // recording the new week after a swipe). + final Map _gridCache = {}; + Object? _cacheKey; @override void initState() { super.initState(); - _firstMonday = _mondayOf(widget.minDate); - final lastMonday = _mondayOf(widget.maxDate); + _firstMonday = widget.minDate.mondayOfWeek; + final lastMonday = widget.maxDate.mondayOfWeek; _totalWeeks = lastMonday.difference(_firstMonday).inDays ~/ 7 + 1; - _currentWeekIndex = - _mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7; - _pageController = PageController(initialPage: _currentWeekIndex); + final initialIndex = + widget.initialDate.mondayOfWeek.difference(_firstMonday).inDays ~/ 7; + _visibleWeekIndex = ValueNotifier(initialIndex); + _reportedWeekIndex = initialIndex; + _pageController = PageController(initialPage: initialIndex); _nowNotifier = ValueNotifier(DateTime.now()); _ticker = Timer.periodic(const Duration(seconds: 30), (_) { if (!mounted) return; final now = DateTime.now(); _nowNotifier.value = now; - final newToday = _dateOnly(now); + final newToday = now.dateOnly; if (newToday != _today) setState(() => _today = newToday); }); } @@ -99,11 +111,11 @@ class CustomWorkWeekCalendarState extends State { // the initial mount with the conservative fallback). Recompute the // range and snap the controller to the page that still represents the // currently visible week so the user doesn't get yanked around. - final newFirstMonday = _mondayOf(widget.minDate); - final newLastMonday = _mondayOf(widget.maxDate); + final newFirstMonday = widget.minDate.mondayOfWeek; + final newLastMonday = widget.maxDate.mondayOfWeek; final newTotalWeeks = newLastMonday.difference(newFirstMonday).inDays ~/ 7 + 1; - final visibleWeekStart = _firstMonday.addDays(_currentWeekIndex * 7); + final visibleWeekStart = _firstMonday.addDays(_visibleWeekIndex.value * 7); final newIndex = visibleWeekStart.difference(newFirstMonday).inDays ~/ 7; final clampedIndex = newIndex.clamp(0, newTotalWeeks - 1); final oldController = _pageController; @@ -112,8 +124,9 @@ class CustomWorkWeekCalendarState extends State { setState(() { _firstMonday = newFirstMonday; _totalWeeks = newTotalWeeks; - _currentWeekIndex = clampedIndex; }); + _visibleWeekIndex.value = clampedIndex; + _reportedWeekIndex = clampedIndex; } @override @@ -121,13 +134,12 @@ class CustomWorkWeekCalendarState extends State { _pageController.dispose(); _ticker.cancel(); _nowNotifier.dispose(); + _visibleWeekIndex.dispose(); super.dispose(); } - static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); - void jumpToDate(DateTime date) { - final target = _mondayOf(date).difference(_firstMonday).inDays ~/ 7; + final target = date.mondayOfWeek.difference(_firstMonday).inDays ~/ 7; if (target < 0 || target >= _totalWeeks) return; _pageController.animateToPage( target, @@ -136,16 +148,79 @@ class CustomWorkWeekCalendarState extends State { ); } - static DateTime _mondayOf(DateTime d) { - final monday = d.subtractDays(d.weekday - 1); - return DateTime(monday.year, monday.month, monday.day); + /// Drops both per-week caches when any input they were built from changed. + void _syncCaches() { + final key = ( + widget.appointments, + widget.timeRegions, + widget.schedule, + widget.onCreateEvent == null, + _today, + _firstMonday, + ); + if (key == _cacheKey) return; + _cacheKey = key; + _weekData.clear(); + _gridCache.clear(); } - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final visibleWeekStart = _firstMonday.addDays(_currentWeekIndex * 7); + _WeekData _weekDataFor(int weekIndex) { + return _weekData.putIfAbsent( + weekIndex, + () => _WeekData.compute( + _firstMonday.addDays(weekIndex * 7), + widget.appointments, + widget.timeRegions, + ), + ); + } + /// Tells the host about the new week only once the swipe has settled, so + /// the resulting bloc emit + rebuild never lands inside the page animation. + bool _onPageScroll(ScrollNotification notification) { + if (notification is! ScrollEndNotification || notification.depth != 0) { + return false; + } + final index = _visibleWeekIndex.value; + if (index == _reportedWeekIndex) return false; + _reportedWeekIndex = index; + final weekStart = _firstMonday.addDays(index * 7); + widget.onWeekChanged(weekStart, weekStart.addDays(4)); + return false; + } + + void _onAppointmentTap(Appointment appointment) => + widget.onAppointmentTap(appointment); + + bool _isCrossedOut(Appointment appointment) => + widget.isCrossedOut(appointment); + + void _onCreateEvent(DateTime start, DateTime end) => + widget.onCreateEvent?.call(start, end); + + _WeekGrid _weekGrid(int weekIndex, PeriodLayout layout) { + final cached = _gridCache[weekIndex]; + if (cached != null && cached.layout.lessonHeight == layout.lessonHeight) { + return cached; + } + // Only the pages next to the visible one are ever alive. + _gridCache.removeWhere((i, _) => (i - weekIndex).abs() > 2); + return _gridCache[weekIndex] = _WeekGrid( + weekStart: _firstMonday.addDays(weekIndex * 7), + schedule: widget.schedule, + data: _weekDataFor(weekIndex), + onAppointmentTap: _onAppointmentTap, + isCrossedOut: _isCrossedOut, + onCreateEvent: widget.onCreateEvent == null ? null : _onCreateEvent, + today: _today, + nowNotifier: _nowNotifier, + rulerWidth: _rulerWidth, + layout: layout, + ); + } + + Widget _buildWeekHeader(int weekIndex) { + final visibleWeekStart = _firstMonday.addDays(weekIndex * 7); return Column( children: [ SizedBox( @@ -190,15 +265,29 @@ class CustomWorkWeekCalendarState extends State { FadeTransition(opacity: animation, child: child), child: _OutsideHoursStrip( key: ValueKey(visibleWeekStart), - weekStart: visibleWeekStart, - appointments: widget.appointments, + outside: _weekDataFor(weekIndex).outside, rulerWidth: _rulerWidth, - onAppointmentTap: widget.onAppointmentTap, - isCrossedOut: widget.isCrossedOut, + onAppointmentTap: _onAppointmentTap, + isCrossedOut: _isCrossedOut, ), ), ), ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + _syncCaches(); + + return Column( + children: [ + ValueListenableBuilder( + valueListenable: _visibleWeekIndex, + builder: (_, weekIndex, _) => _buildWeekHeader(weekIndex), + ), Container(height: 0.5, color: theme.dividerColor.withAlpha(110)), Expanded( child: LayoutBuilder( @@ -225,30 +314,15 @@ class CustomWorkWeekCalendarState extends State { physics: const AlwaysScrollableScrollPhysics(), child: SizedBox( height: gridHeight, - child: PageView.builder( - controller: _pageController, - itemCount: _totalWeeks, - onPageChanged: (index) { - setState(() => _currentWeekIndex = index); - final weekStart = _firstMonday.addDays(index * 7); - widget.onWeekChanged(weekStart, weekStart.addDays(4)); - }, - itemBuilder: (_, weekIndex) { - final weekStart = _firstMonday.addDays(weekIndex * 7); - return _WeekGrid( - weekStart: weekStart, - schedule: widget.schedule, - appointments: widget.appointments, - timeRegions: widget.timeRegions, - onAppointmentTap: widget.onAppointmentTap, - isCrossedOut: widget.isCrossedOut, - onCreateEvent: widget.onCreateEvent, - today: _today, - nowNotifier: _nowNotifier, - rulerWidth: _rulerWidth, - layout: layout, - ); - }, + child: NotificationListener( + onNotification: _onPageScroll, + child: PageView.builder( + controller: _pageController, + itemCount: _totalWeeks, + onPageChanged: (index) => _visibleWeekIndex.value = index, + itemBuilder: (_, weekIndex) => + _weekGrid(weekIndex, layout), + ), ), ), ); diff --git a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart index e7287a8..c48f7fa 100644 --- a/lib/view/pages/timetable/widgets/timetable_calendar_view.dart +++ b/lib/view/pages/timetable/widgets/timetable_calendar_view.dart @@ -7,10 +7,10 @@ import '../../../../extensions/date_time.dart'; import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; -import '../../../../storage/timetable_settings.dart'; import '../data/arbitrary_appointment.dart'; import '../data/lesson_period_schedule.dart'; import '../data/timetable_appointment_factory.dart'; +import '../data/timetable_name_mode.dart'; import 'custom_workweek_calendar.dart'; import 'special_regions_builder.dart'; @@ -51,9 +51,17 @@ class TimetableCalendarViewState extends State { GlobalKey(); List? _cachedAppointments; - // TimetableSettings and List define no `==`, so record equality degrades to - // the same identity checks the cache always used. - (int, TimetableSettings, List, bool)? _cacheKey; + // Settings are keyed by the values the factory reads: the settings object is + // re-created on every settings write, so its identity would miss the cache + // for unrelated changes. The event list has no `==` and compares by identity. + (int, bool, TimetableNameMode, List, bool)? _cacheKey; + + // Stable identities let the calendar reuse its per-week pages across + // rebuilds; rebuilding these every frame would invalidate that cache. + LessonPeriodSchedule? _schedule; + Object? _scheduleKey; + List? _regions; + Object? _regionsKey; DateTime _initialDisplayDate() => DateTime.now().addDays(2); @@ -64,13 +72,16 @@ class TimetableCalendarViewState extends State { } List _appointments(TimetableState state) { - final timetableSettings = context - .watch() - .val() - .timetableSettings; + final (connectDoubleLessons, nameMode) = context.select( + (SettingsCubit c) => ( + c.state.timetableSettings.connectDoubleLessons, + c.state.timetableSettings.timetableNameMode, + ), + ); final key = ( state.dataVersion, - timetableSettings, + connectDoubleLessons, + nameMode, widget.customEvents, widget.showClassInsteadOfTeacher, ); @@ -84,13 +95,43 @@ class TimetableCalendarViewState extends State { customEvents: widget.customEvents, subjects: state.subjects?.result ?? const [], holidays: state.schoolHolidays?.result ?? const [], - settings: timetableSettings, + settings: context.read().val().timetableSettings, now: DateTime.now(), showClassInsteadOfTeacher: widget.showClassInsteadOfTeacher, ).build(); } - bool _isCrossedOut(Appointment appointment) { + LessonPeriodSchedule _scheduleFor(TimetableState state) { + if (_schedule != null && identical(_scheduleKey, state.timegrid)) { + return _schedule!; + } + _scheduleKey = state.timegrid; + return _schedule = LessonPeriodSchedule.fromState(state); + } + + List _regionsFor( + TimetableState state, + LessonPeriodSchedule schedule, + ) { + final theme = Theme.of(context); + final key = ( + state.schoolHolidays, + schedule, + theme.colorScheme, + theme.disabledColor, + DateTime.now().dateOnly, + ); + if (_regions != null && key == _regionsKey) return _regions!; + _regionsKey = key; + return _regions = SpecialRegionsBuilder( + holidays: state.schoolHolidays!, + schedule: schedule, + colorScheme: theme.colorScheme, + disabledColor: theme.disabledColor, + ).build(); + } + + static bool _isCrossedOut(Appointment appointment) { final id = appointment.id; if (id is LessonAppointment) return id.entry.status == 'CANCELLED'; return false; @@ -104,14 +145,9 @@ class TimetableCalendarViewState extends State { // transient null can never crash the build. if (state.schoolHolidays == null) return const SizedBox.shrink(); - final schedule = LessonPeriodSchedule.fromState(state); + final schedule = _scheduleFor(state); final appointments = _appointments(state); - final regions = SpecialRegionsBuilder( - holidays: state.schoolHolidays!, - schedule: schedule, - colorScheme: Theme.of(context).colorScheme, - disabledColor: Theme.of(context).disabledColor, - ).build(); + final regions = _regionsFor(state, schedule); final capabilities = context.watch(); final (minDate, maxDate) = _scrollBounds( @@ -171,8 +207,8 @@ class TimetableCalendarViewState extends State { ? state.accessibleEndDate! : baseMax) : baseMax; - final today = _startOfDay(DateTime.now()); - final todayMonday = _mondayOf(today); + final today = DateTime.now().dateOnly; + final todayMonday = today.mondayOfWeek; final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1); final capMin = pastDays == null ? null @@ -202,11 +238,6 @@ class TimetableCalendarViewState extends State { return (mondayMin, finalMax); } - static DateTime _mondayOf(DateTime d) => - _startOfDay(d.subtractDays(d.weekday - 1)); - - static DateTime _startOfDay(DateTime d) => DateTime(d.year, d.month, d.day); - static DateTime _earlier(DateTime a, DateTime b) => a.isBefore(b) ? a : b; static DateTime _later(DateTime a, DateTime b) => a.isAfter(b) ? a : b; diff --git a/lib/widget/a11y/a11y_labels.dart b/lib/widget/a11y/a11y_labels.dart index 806ce6f..e0af333 100644 --- a/lib/widget/a11y/a11y_labels.dart +++ b/lib/widget/a11y/a11y_labels.dart @@ -14,6 +14,7 @@ abstract final class A11yLabels { static const unread = 'Ungelesen'; static const favorite = 'Favorit'; static const draft = 'Entwurf'; + static const muted = 'Stummgeschaltet'; static const yourReaction = 'deine Reaktion'; // Bilder / Kontakte diff --git a/lib/widget/avatar_actions_sheet.dart b/lib/widget/avatar_actions_sheet.dart index 5e1a1e0..901975e 100644 --- a/lib/widget/avatar_actions_sheet.dart +++ b/lib/widget/avatar_actions_sheet.dart @@ -42,7 +42,7 @@ Future showAvatarActionsSheet( onTap: () async { final bytes = await _pickAndCrop( sheetContext, - () => FilePick.singleGalleryPick(sheetContext), + () => FilePick.singleGalleryPick(sheetContext, forCrop: true), ); if (bytes == null || !sheetContext.mounted) return; result = AvatarUploadResult(bytes); @@ -55,7 +55,7 @@ Future showAvatarActionsSheet( onTap: () async { final bytes = await _pickAndCrop( sheetContext, - () => FilePick.cameraPick(sheetContext), + () => FilePick.cameraPick(sheetContext, forCrop: true), ); if (bytes == null || !sheetContext.mounted) return; result = AvatarUploadResult(bytes); diff --git a/lib/widget/avatar_disk_cache.dart b/lib/widget/avatar_disk_cache.dart index 14801b7..2d038f4 100644 --- a/lib/widget/avatar_disk_cache.dart +++ b/lib/widget/avatar_disk_cache.dart @@ -5,6 +5,8 @@ import 'dart:typed_data'; import 'package:path_provider/path_provider.dart'; +import '../utils/directory_size.dart'; + /// Persistent disk cache for the in-app [UserAvatar] widget. /// /// The widget keeps a session-scoped in-memory LRU; this store survives app @@ -156,6 +158,8 @@ class AvatarDiskCache { } } + Future totalSize() async => directorySize(await _directory()); + /// Wipes the whole cache — used by the argument-less `invalidateAvatarCache` /// (e.g. on logout). Future clear() async { diff --git a/lib/widget/chat_background.dart b/lib/widget/chat_background.dart index 2505413..8118fb6 100644 --- a/lib/widget/chat_background.dart +++ b/lib/widget/chat_background.dart @@ -8,6 +8,7 @@ import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../storage/chat_background_settings.dart'; import '../theming/app_theme.dart'; import '../utils/app_paths.dart'; +import '../utils/screen_bound_image.dart'; /// Renders the configurable chat background behind [child]. /// @@ -24,7 +25,13 @@ class ChatBackground extends StatelessWidget { @override Widget build(BuildContext context) { - final s = context.watch().val().chatBackgroundSettings; + // Value snapshot: the settings object is mutated in place, so only copied + // values can tell whether the background actually changed. + context.select((SettingsCubit c) { + final s = c.state.chatBackgroundSettings; + return (s.type, s.fit, s.colorValue, s.imageVersion, s.dim, s.blur); + }); + final s = context.read().val().chatBackgroundSettings; final dark = AppTheme.isDarkMode(context); final Widget background; @@ -32,7 +39,9 @@ class ChatBackground extends StatelessWidget { case ChatBackgroundType.none: background = ColoredBox(color: Theme.of(context).colorScheme.surface); case ChatBackgroundType.color: - background = ColoredBox(color: Color(s.colorValue ?? _fallbackColor.toARGB32())); + background = ColoredBox( + color: Color(s.colorValue ?? _fallbackColor.toARGB32()), + ); case ChatBackgroundType.pattern: background = _imageLayer( const AssetImage('assets/background/chat.png'), @@ -41,12 +50,17 @@ class ChatBackground extends StatelessWidget { isPattern: true, ); case ChatBackgroundType.image: + final image = FileImage(File(AppPaths.chatBackgroundImage)); background = KeyedSubtree( // imageVersion changes on every replacement, forcing a fresh subtree // alongside the explicit ImageCache evict in the settings handler. key: ValueKey(s.imageVersion), + // Only "cover" scales the photo to the screen; tile/center render + // it unscaled, so those keep the natural size. child: _imageLayer( - FileImage(File(AppPaths.chatBackgroundImage)), + s.fit == ChatBackgroundFit.cover + ? screenBoundImage(context, image) + : image, s, dark, isPattern: false, @@ -57,7 +71,9 @@ class ChatBackground extends StatelessWidget { return Stack( fit: StackFit.expand, children: [ - Positioned.fill(child: background), + // Own layer: without it every scroll frame of the message list above + // repaints the (possibly tiled or blurred) background as well. + Positioned.fill(child: RepaintBoundary(child: background)), if (s.dim > 0) Positioned.fill( child: ColoredBox(color: Colors.black.withValues(alpha: s.dim)), diff --git a/lib/widget/chat_background_picker_sheet.dart b/lib/widget/chat_background_picker_sheet.dart index 6f29474..cbae7d0 100644 --- a/lib/widget/chat_background_picker_sheet.dart +++ b/lib/widget/chat_background_picker_sheet.dart @@ -22,7 +22,7 @@ Future showChatBackgroundPickerSheet(BuildContext context) async { title: const Text('Aus Galerie wählen'), onTap: () async { final bytes = await _pickRaw( - () => FilePick.singleGalleryPick(sheetContext), + () => FilePick.singleGalleryPick(sheetContext, forCrop: true), ); if (bytes == null || !sheetContext.mounted) return; result = bytes; @@ -33,7 +33,9 @@ Future showChatBackgroundPickerSheet(BuildContext context) async { leading: const Icon(Icons.photo_camera_outlined), title: const Text('Foto aufnehmen'), onTap: () async { - final bytes = await _pickRaw(() => FilePick.cameraPick(sheetContext)); + final bytes = await _pickRaw( + () => FilePick.cameraPick(sheetContext, forCrop: true), + ); if (bytes == null || !sheetContext.mounted) return; result = bytes; Navigator.of(sheetContext).pop(); diff --git a/lib/widget/debug/bloc_storage_view.dart b/lib/widget/debug/bloc_storage_view.dart new file mode 100644 index 0000000..e3502b1 --- /dev/null +++ b/lib/widget/debug/bloc_storage_view.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; + +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import '../../extensions/date_time.dart'; +import '../../routing/app_routes.dart'; +import '../../state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; +import '../../state/app/modules/breaker/bloc/breaker_bloc.dart'; +import '../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; +import '../../state/app/modules/chat/bloc/chat_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/settings/bloc/settings_cubit.dart'; +import '../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../storage/account_storage.dart'; +import '../../storage/hydrated_storage_bootstrap.dart'; +import '../confirm_dialog.dart'; +import '../placeholder_view.dart'; +import 'json_viewer.dart'; + +/// Lists every key persisted by the active account's blocs and the app-wide +/// settings. +class BlocStorageView extends StatefulWidget { + const BlocStorageView({super.key}); + + static List get _storages => [ + HydratedBloc.storage, + AccountStorage.global, + ]; + + /// All persisted keys; `null` when a storage can't be enumerated. + static List? keys() { + final keys = {}; + for (final storage in _storages) { + final own = hydratedStorageKeys(storage); + if (own == null) return null; + keys.addAll(own); + } + return keys.toList()..sort(); + } + + static Storage _storageOf(String key) => _storages.firstWhere( + (storage) => hydratedStorageKeys(storage)?.contains(key) ?? false, + orElse: () => HydratedBloc.storage, + ); + + @override + State createState() => _BlocStorageViewState(); +} + +class _Entry { + final String key; + final Object? value; + final int size; + final DateTime? lastFetch; + + _Entry(this.key, this.value) + : size = utf8.encode(jsonEncode(JsonViewer.toEncodable(value))).length, + lastFetch = _lastFetch(value); + + static DateTime? _lastFetch(Object? value) { + if (value is! Map) return null; + final meta = value['meta']; + if (meta is! Map || meta['timestamp'] is! int) return null; + final ts = meta['timestamp'] as int; + return ts > 0 ? DateTime.fromMillisecondsSinceEpoch(ts) : null; + } +} + +class _BlocStorageViewState extends State { + List<_Entry>? _entries; + + @override + void initState() { + super.initState(); + _load(); + } + + void _load() { + final keys = BlocStorageView.keys(); + setState( + () => _entries = keys + ?.map((k) => _Entry(k, BlocStorageView._storageOf(k).read(k))) + .toList(), + ); + } + + /// Blocs that live for the whole app session would simply re-persist their + /// in-memory state, so those are reset through the bloc itself. + Future Function()? _liveReset(String key) { + Future Function() loadable( + LoadableHydratedBloc bloc, + ) => () async { + await bloc.reset(); + bloc.retry(); + }; + return switch (key) { + 'TimetableBloc' => loadable(context.read()), + 'ChatListBloc' => loadable(context.read()), + 'ChatBloc' => loadable(context.read()), + 'BreakerBloc' => loadable(context.read()), + 'CapabilitiesCubit' => () async { + final cubit = context.read(); + await cubit.reset(); + await cubit.load(); + }, + 'NextcloudCapabilitiesCubit' => () async { + final cubit = context.read(); + await cubit.reset(); + await cubit.load(); + }, + 'SettingsCubit' => context.read().reset, + _ => null, + }; + } + + Future _delete(String key) async { + final reset = _liveReset(key); + if (reset != null) { + await reset(); + } else { + await BlocStorageView._storageOf(key).delete(key); + } + } + + void _confirmDelete(_Entry entry) { + final isSettings = entry.key == 'SettingsCubit'; + ConfirmDialog( + title: isSettings ? 'Einstellungen zurücksetzen' : 'Eintrag löschen', + content: isSettings + ? 'Alle Einstellungen werden auf die Standardwerte zurückgesetzt.' + : '„${entry.key}“ wird gelöscht und bei Bedarf neu geladen.', + confirmButton: 'Löschen', + errorBuilder: (e) => '$e', + onConfirmAsync: () async { + await _delete(entry.key); + if (mounted) _load(); + }, + ).asDialog(context); + } + + void _confirmDeleteAll() { + ConfirmDialog( + title: 'BLoC-Speicher leeren', + content: + 'Alle gespeicherten Modul-Zustände außer den Einstellungen werden ' + 'gelöscht und bei Bedarf neu geladen.', + confirmButton: 'Alles löschen', + errorBuilder: (e) => '$e', + onConfirmAsync: () async { + for (final entry in [...?_entries]) { + if (entry.key == 'SettingsCubit') continue; + await _delete(entry.key); + } + if (mounted) _load(); + }, + ).asDialog(context); + } + + @override + Widget build(BuildContext context) { + final entries = _entries; + return Scaffold( + appBar: AppBar( + title: const Text('BLoC-Speicher'), + actions: [ + IconButton( + tooltip: 'Neu laden', + icon: const Icon(Icons.refresh), + onPressed: _load, + ), + if (entries != null && entries.isNotEmpty) + IconButton( + tooltip: 'Alles löschen', + icon: const Icon(Icons.delete_sweep_outlined), + onPressed: _confirmDeleteAll, + ), + ], + ), + body: entries == null + ? const PlaceholderView( + icon: Icons.error_outline, + text: 'Speicher lässt sich nicht auflisten', + ) + : entries.isEmpty + ? const PlaceholderView( + icon: Icons.hourglass_empty, + text: 'Keine Daten', + ) + : ListView.builder( + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + final live = _liveReset(entry.key) != null; + return ListTile( + leading: Icon( + live ? Icons.bolt_outlined : Icons.inventory_2_outlined, + ), + title: Text(entry.key), + subtitle: Text( + [ + filesize(entry.size), + if (entry.lastFetch != null) + 'geladen ${entry.lastFetch!.formatRelative()}', + ].join(' · '), + ), + onTap: () => AppRoutes.openJsonViewer( + context, + entry.value, + title: entry.key, + ), + trailing: IconButton( + tooltip: 'Löschen', + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmDelete(entry), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widget/debug/cache_view.dart b/lib/widget/debug/cache_view.dart index ff78296..4a98c61 100644 --- a/lib/widget/debug/cache_view.dart +++ b/lib/widget/debug/cache_view.dart @@ -1,84 +1,116 @@ import 'dart:convert'; + import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:localstore/localstore.dart'; -import '../../../widget/placeholder_view.dart'; -import '../../api/request_cache.dart'; +import '../../api/cache_store.dart'; +import '../../extensions/date_time.dart'; +import '../../routing/app_routes.dart'; import '../app_progress_indicator.dart'; -import 'json_viewer.dart'; +import '../confirm_dialog.dart'; +import '../placeholder_view.dart'; +/// Lists the [CacheStore] entries (one file per request key), newest first. class CacheView extends StatefulWidget { const CacheView({super.key}); @override State createState() => _CacheViewState(); - - Future clear() async { - await Localstore.instance.collection(RequestCache.collection).delete(); - } - - Future totalSize() async { - final data = await Localstore.instance - .collection(RequestCache.collection) - .get(); - if (data == null || data.isEmpty) return 0; - return data.values.fold( - 0, - (sum, value) => sum + jsonEncode(value).length, - ) * - 8; - } } class _CacheViewState extends State { - late Future?> files; + late Future>> _entries = _read(); - @override - void initState() { - files = Localstore.instance.collection(RequestCache.collection).get(); - super.initState(); + Future>> _read() async => + (await CacheStore.instance.readAll()).entries.toList() + ..sort((a, b) => b.value.lastUpdate.compareTo(a.value.lastUpdate)); + + void _reload() => setState(() { + _entries = _read(); + }); + + void _confirmDelete(String key) { + ConfirmDialog( + title: 'Eintrag löschen', + content: '„$key“ wird gelöscht und bei Bedarf neu geladen.', + confirmButton: 'Löschen', + errorBuilder: (e) => '$e', + onConfirmAsync: () async { + await CacheStore.instance.delete(key); + if (mounted) _reload(); + }, + ).asDialog(context); + } + + void _confirmClear() { + ConfirmDialog( + title: 'Request-Cache leeren', + content: + 'Alle Einträge werden gelöscht. Der Cache baut sich bei Nutzung der ' + 'App automatisch wieder auf.', + confirmButton: 'Alles löschen', + errorBuilder: (e) => '$e', + onConfirmAsync: () async { + await CacheStore.instance.clear(); + if (mounted) _reload(); + }, + ).asDialog(context); } @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('Cache storage')), + appBar: AppBar( + title: const Text('Request-Cache'), + actions: [ + IconButton( + tooltip: 'Neu laden', + icon: const Icon(Icons.refresh), + onPressed: _reload, + ), + IconButton( + tooltip: 'Alles löschen', + icon: const Icon(Icons.delete_sweep_outlined), + onPressed: _confirmClear, + ), + ], + ), body: FutureBuilder( - future: files, + future: _entries, builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - final key = snapshot.data!.keys.elementAt(index); - final element = snapshot.data![key] as Map; - final filename = key.split('/').last; - - return ListTile( - leading: const Icon(Icons.text_snippet_outlined), - title: Text(filename), - subtitle: Text( - '${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate'] as int).fromNow()}', - ), - trailing: const Icon(Icons.arrow_right), - onTap: () => JsonViewer.asDialog( - context, - jsonDecode(element['json'] as String) as Map, - ), - ); - }, - ); - } else if (snapshot.connectionState != ConnectionState.done) { + if (snapshot.connectionState != ConnectionState.done) { return const Center(child: AppProgressIndicator.large()); - } else { - return const Center( - child: PlaceholderView( - icon: Icons.hourglass_empty, - text: 'Keine Daten', - ), + } + final entries = snapshot.data ?? const []; + if (entries.isEmpty) { + return const PlaceholderView( + icon: Icons.hourglass_empty, + text: 'Keine Daten', ); } + return ListView.builder( + itemCount: entries.length, + itemBuilder: (context, index) { + final MapEntry(:key, :value) = entries[index]; + return ListTile( + leading: const Icon(Icons.text_snippet_outlined), + title: Text(key), + subtitle: Text( + '${filesize(utf8.encode(value.json).length)} · ' + '${DateTime.fromMillisecondsSinceEpoch(value.lastUpdate).formatRelative()}', + ), + onTap: () => AppRoutes.openJsonViewer( + context, + jsonDecode(value.json), + title: key, + ), + trailing: IconButton( + tooltip: 'Löschen', + icon: const Icon(Icons.delete_outline), + onPressed: () => _confirmDelete(key), + ), + ); + }, + ); }, ), ); diff --git a/lib/widget/debug/debug_stats_overlay.dart b/lib/widget/debug/debug_stats_overlay.dart new file mode 100644 index 0000000..b23e2fb --- /dev/null +++ b/lib/widget/debug/debug_stats_overlay.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +/// Release-capable frame and memory statistics drawn above [child]. Frame data +/// comes from [SchedulerBinding.addTimingsCallback], which the engine reports +/// in batches (about once per second in release builds). +class DebugStatsOverlay extends StatefulWidget { + final bool showFrameStats; + final bool showMemoryStats; + final Widget child; + + const DebugStatsOverlay({ + required this.showFrameStats, + required this.showMemoryStats, + required this.child, + super.key, + }); + + @override + State createState() => _DebugStatsOverlayState(); +} + +class _DebugStatsOverlayState extends State { + static const _sampleWindow = 240; + + final List _frames = []; + int _jankTotal = 0; + int _frameTotal = 0; + Timer? _ticker; + bool _listening = false; + + bool get _active => widget.showFrameStats || widget.showMemoryStats; + + @override + void initState() { + super.initState(); + _sync(); + } + + @override + void didUpdateWidget(DebugStatsOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + _sync(); + } + + void _sync() { + if (widget.showFrameStats && !_listening) { + SchedulerBinding.instance.addTimingsCallback(_onTimings); + _listening = true; + } else if (!widget.showFrameStats && _listening) { + SchedulerBinding.instance.removeTimingsCallback(_onTimings); + _listening = false; + _frames.clear(); + _jankTotal = 0; + _frameTotal = 0; + } + if (_active) { + _ticker ??= Timer.periodic( + const Duration(seconds: 1), + (_) => setState(() {}), + ); + } else { + _ticker?.cancel(); + _ticker = null; + } + } + + Duration get _budget { + final rate = View.maybeOf(context)?.display.refreshRate ?? 60; + return Duration(microseconds: (1e6 / (rate > 0 ? rate : 60)).round()); + } + + void _onTimings(List timings) { + final budget = _budget; + for (final t in timings) { + _frameTotal++; + if (t.buildDuration > budget || t.rasterDuration > budget) _jankTotal++; + } + _frames.addAll(timings); + if (_frames.length > _sampleWindow) { + _frames.removeRange(0, _frames.length - _sampleWindow); + } + } + + @override + void dispose() { + if (_listening) SchedulerBinding.instance.removeTimingsCallback(_onTimings); + _ticker?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_active) return widget.child; + return Stack( + textDirection: TextDirection.ltr, + children: [ + widget.child, + Positioned( + left: 4, + bottom: MediaQuery.paddingOf(context).bottom + 4, + child: IgnorePointer( + child: MediaQuery.withNoTextScaling( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(4), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + child: DefaultTextStyle( + style: const TextStyle( + color: Colors.white, + fontSize: 10, + height: 1.3, + fontFamily: 'monospace', + decoration: TextDecoration.none, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showFrameStats) ..._frameLines(), + if (widget.showMemoryStats) ..._memoryLines(), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ); + } + + List _frameLines() { + final budgetMs = _budget.inMicroseconds / 1000; + if (_frames.isEmpty) { + return [Text('Frames: – (Budget ${budgetMs.toStringAsFixed(1)} ms)')]; + } + final build = _sortedMs((t) => t.buildDuration); + final raster = _sortedMs((t) => t.rasterDuration); + final jankPct = _frameTotal == 0 ? 0 : _jankTotal * 100 / _frameTotal; + final lastWallclock = _frames.last.timestampInMicroseconds( + FramePhase.rasterFinish, + ); + final recent = _frames + .where( + (t) => + lastWallclock - + t.timestampInMicroseconds(FramePhase.rasterFinish) < + 1000000, + ) + .length; + return [ + Text( + 'Budget ${budgetMs.toStringAsFixed(1)} ms · letzte Sek. $recent Frames', + ), + Text( + 'Build p50 ${_p(build, 50)} p90 ${_p(build, 90)} max ${_ms(build.last)}', + ), + Text( + 'Raster p50 ${_p(raster, 50)} p90 ${_p(raster, 90)} max ${_ms(raster.last)}', + ), + Text('Jank $_jankTotal/$_frameTotal (${jankPct.toStringAsFixed(1)} %)'), + ]; + } + + List _memoryLines() { + final cache = PaintingBinding.instance.imageCache; + return [ + Text( + 'RSS ${filesize(ProcessInfo.currentRss)} · max ${filesize(ProcessInfo.maxRss)}', + ), + Text( + 'Bilder ${cache.currentSize}/${cache.maximumSize} · ' + '${filesize(cache.currentSizeBytes)}/${filesize(cache.maximumSizeBytes)} · ' + 'live ${cache.liveImageCount}', + ), + ]; + } + + List _sortedMs(Duration Function(FrameTiming) pick) => + _frames.map((t) => pick(t).inMicroseconds / 1000).toList()..sort(); + + String _p(List sorted, int percentile) { + final i = min( + sorted.length - 1, + (sorted.length * percentile / 100).floor(), + ); + return _ms(sorted[i]); + } + + String _ms(double v) => v.toStringAsFixed(1).padLeft(5); +} diff --git a/lib/widget/debug/debug_tile.dart b/lib/widget/debug/debug_tile.dart index 389ff5f..d47d475 100644 --- a/lib/widget/debug/debug_tile.dart +++ b/lib/widget/debug/debug_tile.dart @@ -2,9 +2,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../routing/app_routes.dart'; import '../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../centered_leading.dart'; -import 'json_viewer.dart'; class DebugTile { BuildContext context; @@ -16,9 +16,9 @@ class DebugTile { (onlyInDebug ? kDebugMode : true); Widget jsonData(Map data) => callback( - title: 'JSON daten anzeigen', - onTab: () => JsonViewer.asDialog(context, data), - ); + title: 'JSON daten anzeigen', + onTab: () => AppRoutes.openJsonViewer(context, data), + ); Widget callback({ String title = 'Debugaktion', diff --git a/lib/widget/debug/json_viewer.dart b/lib/widget/debug/json_viewer.dart index 01f7cd4..b7f49db 100644 --- a/lib/widget/debug/json_viewer.dart +++ b/lib/widget/debug/json_viewer.dart @@ -4,43 +4,201 @@ import 'package:flutter/material.dart'; import '../../utils/clipboard_helper.dart'; -class JsonViewer { +/// Collapsible tree view for decoded JSON (maps, lists, scalars). Rows are +/// flattened lazily so multi-megabyte cache payloads stay scrollable. +class JsonViewer extends StatefulWidget { + final String title; + final Object? data; + + const JsonViewer({required this.data, this.title = 'Rohdaten', super.key}); + static final _encoder = const JsonEncoder.withIndent(' '); - static String format(Map jsonInput) => - _encoder.convert(jsonInput); + static String format(Object? data) => _encoder.convert(toEncodable(data)); - static void asDialog(BuildContext context, Map dataMap) { - showDialog( - context: context, - builder: (dialogCtx) => AlertDialog( - scrollable: true, - title: const Row( - children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')], + /// Makes arbitrary decoded storage values (Hive maps with dynamic keys, + /// DateTimes, …) JSON-encodable. + static Object? toEncodable(Object? value) => switch (value) { + null || bool() || num() || String() => value, + Map() => {for (final e in value.entries) '${e.key}': toEncodable(e.value)}, + Iterable() => [for (final e in value) toEncodable(e)], + DateTime() => value.toIso8601String(), + _ => value.toString(), + }; + + @override + State createState() => _JsonViewerState(); +} + +class _Row { + final String path; + final String? label; + final Object? value; + final int depth; + + const _Row(this.path, this.label, this.value, this.depth); + + bool get isContainer => value is Map || value is List; +} + +class _JsonViewerState extends State { + late final Object? _data = JsonViewer.toEncodable(widget.data); + final Set _expanded = {''}; + List<_Row> _rows = const []; + + @override + void initState() { + super.initState(); + _rebuildRows(); + } + + void _rebuildRows() { + final rows = <_Row>[]; + void walk(String path, String? label, Object? value, int depth) { + rows.add(_Row(path, label, value, depth)); + if (!_expanded.contains(path)) return; + if (value is Map) { + for (final e in value.entries) { + walk('$path/${e.key}', '${e.key}', e.value, depth + 1); + } + } else if (value is List) { + for (var i = 0; i < value.length; i++) { + walk('$path/$i', '[$i]', value[i], depth + 1); + } + } + } + + walk('', null, _data, 0); + _rows = rows; + } + + void _toggle(String path) => setState(() { + if (!_expanded.remove(path)) _expanded.add(path); + _rebuildRows(); + }); + + void _setAll({required bool expanded}) => setState(() { + _expanded + ..clear() + ..add(''); + if (expanded) { + void collect(String path, Object? value) { + if (value is Map) { + _expanded.add(path); + for (final e in value.entries) { + collect('$path/${e.key}', e.value); + } + } else if (value is List) { + _expanded.add(path); + for (var i = 0; i < value.length; i++) { + collect('$path/$i', value[i]); + } + } + } + + collect('', _data); + } + _rebuildRows(); + }); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text(widget.title), + actions: [ + IconButton( + tooltip: 'Alles aufklappen', + icon: const Icon(Icons.unfold_more), + onPressed: () => _setAll(expanded: true), ), - content: Text(JsonViewer.format(dataMap)), - actions: [ - TextButton( - onPressed: () => copyToClipboard( - dialogCtx, - JsonViewer.format(dataMap), - successMessage: 'Formatiertes JSON kopiert', + IconButton( + tooltip: 'Alles zuklappen', + icon: const Icon(Icons.unfold_less), + onPressed: () => _setAll(expanded: false), + ), + PopupMenuButton( + onSelected: (pretty) => copyToClipboard( + context, + pretty ? JsonViewer.format(_data) : jsonEncode(_data), + successMessage: pretty + ? 'Formatiertes JSON kopiert' + : 'Inline JSON kopiert', + ), + itemBuilder: (_) => const [ + PopupMenuItem(value: true, child: Text('JSON kopieren')), + PopupMenuItem(value: false, child: Text('Inline kopieren')), + ], + ), + ], + ), + body: ListView.builder( + itemCount: _rows.length, + itemBuilder: (context, index) => _buildRow(context, _rows[index]), + ), + ); + + Widget _buildRow(BuildContext context, _Row row) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final mono = theme.textTheme.bodyMedium!.copyWith(fontFamily: 'monospace'); + final value = row.value; + final expanded = _expanded.contains(row.path); + + final (String text, Color color) = switch (value) { + Map() => (expanded ? '{' : '{ ${value.length} }', colors.outline), + List() => (expanded ? '[' : '[ ${value.length} ]', colors.outline), + String() => ('"$value"', colors.tertiary), + num() || bool() => ('$value', colors.primary), + _ => ('null', colors.outline), + }; + + return InkWell( + onTap: row.isContainer ? () => _toggle(row.path) : null, + onLongPress: () => copyToClipboard( + context, + value is String ? value : JsonViewer.format(value), + ), + child: Padding( + padding: EdgeInsets.only( + left: 8.0 + row.depth * 16, + right: 8, + top: 3, + bottom: 3, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 20, + child: row.isContainer + ? Icon( + expanded ? Icons.arrow_drop_down : Icons.arrow_right, + size: 20, + ) + : null, ), - child: const Text('Kopieren'), - ), - TextButton( - onPressed: () => copyToClipboard( - dialogCtx, - dataMap.toString(), - successMessage: 'Inline JSON kopiert', + Expanded( + child: Text.rich( + TextSpan( + style: mono, + children: [ + if (row.label != null) + TextSpan( + text: '${row.label}: ', + style: TextStyle(color: colors.secondary), + ), + TextSpan( + text: text, + style: TextStyle(color: color), + ), + ], + ), + maxLines: row.isContainer ? 1 : 8, + overflow: TextOverflow.ellipsis, + ), ), - child: const Text('Inline Kopieren'), - ), - TextButton( - onPressed: () => Navigator.of(dialogCtx).pop(), - child: const Text('Schließen'), - ), - ], + ], + ), ), ); } diff --git a/lib/widget/file_pick.dart b/lib/widget/file_pick.dart index 40be7e0..7f2578e 100644 --- a/lib/widget/file_pick.dart +++ b/lib/widget/file_pick.dart @@ -23,11 +23,31 @@ class FilePick { return pickedImages.isNotEmpty ? pickedImages : null; }); - static Future singleGalleryPick(BuildContext context) => - _guarded(context, () => _picker.pickImage(source: ImageSource.gallery)); + static Future singleGalleryPick( + BuildContext context, { + bool forCrop = false, + }) => _pickImage(context, ImageSource.gallery, forCrop: forCrop); - static Future cameraPick(BuildContext context) => - _guarded(context, () => _picker.pickImage(source: ImageSource.camera)); + static Future cameraPick( + BuildContext context, { + bool forCrop = false, + }) => _pickImage(context, ImageSource.camera, forCrop: forCrop); + + /// [forCrop] makes the picker re-encode to a bounded JPEG: the Dart cropper + /// can't decode HEIC (iOS gallery default) and is slow on full-size photos. + static Future _pickImage( + BuildContext context, + ImageSource source, { + required bool forCrop, + }) => _guarded( + context, + () => _picker.pickImage( + source: source, + maxWidth: forCrop ? 2048 : null, + maxHeight: forCrop ? 2048 : null, + imageQuality: forCrop ? 90 : null, + ), + ); static Future?> documentPick() async { final result = await FilePicker.pickFiles(allowMultiple: true); diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index e659ead..17a0b2d 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:math'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -15,6 +16,7 @@ import '../routing/app_routes.dart'; import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../utils/downloads/download_manager.dart'; +import '../utils/screen_bound_image.dart'; import 'app_progress_indicator.dart'; import 'async_action_button.dart'; import 'centered_leading.dart'; @@ -49,6 +51,7 @@ class FileViewer extends StatefulWidget { enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud } class _FileViewerState extends State { + Future<_TextPayload>? _textPayload; final PhotoViewController photoViewController = PhotoViewController(); late SettingsCubit settings = context.read(); @@ -302,7 +305,13 @@ class _FileViewerState extends State { controller: photoViewController, maxScale: 3.0, minScale: 0.1, - imageProvider: Image.file(File(widget.path)).image, + // 2× the screen stays sharp while zooming in; the 4096 px cap only + // bites on camera-sized photos. + imageProvider: screenBoundImage( + context, + FileImage(File(widget.path)), + scale: 2, + ), backgroundDecoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, ), @@ -348,13 +357,17 @@ class _FileViewerState extends State { Widget _buildTextView() => Scaffold( appBar: _appbar(), body: FutureBuilder<_TextPayload>( - future: _readTextPayload(), + // Cached: a future created in build re-read the file on every rebuild. + future: _textPayload ??= compute( + _loadTextPayload, + (widget.path, _textViewMaxBytes), + ), builder: (context, snapshot) { if (!snapshot.hasData) { return const Center(child: AppProgressIndicator.large()); } final payload = snapshot.data!; - final lines = const LineSplitter().convert(payload.content); + final lines = payload.lines; // Stable gutter width — sized by the highest line number's digit count. final gutterWidth = (lines.length.toString().length * 9.0) + 16; return SelectionArea( @@ -443,38 +456,6 @@ class _FileViewerState extends State { } static const int _textViewMaxBytes = 5 * 1024 * 1024; - - Future<_TextPayload> _readTextPayload() async { - final file = File(widget.path); - final size = await file.length(); - final ext = widget.path.split('.').last.toLowerCase(); - if (size <= _textViewMaxBytes) { - final raw = await file.readAsString(); - return _TextPayload(content: _maybePrettify(raw, ext), truncated: false); - } - final raf = await file.open(); - try { - final bytes = await raf.read(_textViewMaxBytes); - // Truncated payloads stay raw — a parser would choke on the dangling tail. - return _TextPayload( - content: utf8.decode(bytes, allowMalformed: true), - truncated: true, - ); - } finally { - await raf.close(); - } - } - - /// Falls through to the original text on parse errors. - String _maybePrettify(String content, String ext) { - if (ext != 'json') return content; - try { - final parsed = jsonDecode(content); - return const JsonEncoder.withIndent(' ').convert(parsed); - } on Object { - return content; - } - } } class _ActionDescriptor { @@ -489,7 +470,47 @@ class _ActionDescriptor { } class _TextPayload { - final String content; + final List lines; final bool truncated; - const _TextPayload({required this.content, required this.truncated}); + const _TextPayload({required this.lines, required this.truncated}); +} + +/// Reads, prettifies (JSON) and splits a text file on a background isolate: +/// up to 5 MB of decoding and line splitting would otherwise freeze the UI. +Future<_TextPayload> _loadTextPayload((String, int) args) async { + final (path, maxBytes) = args; + final file = File(path); + final size = await file.length(); + final ext = path.split('.').last.toLowerCase(); + if (size <= maxBytes) { + final raw = await file.readAsString(); + return _TextPayload( + lines: const LineSplitter().convert(_maybePrettify(raw, ext)), + truncated: false, + ); + } + final raf = await file.open(); + try { + final bytes = await raf.read(maxBytes); + // Truncated payloads stay raw — a parser would choke on the dangling tail. + return _TextPayload( + lines: const LineSplitter().convert( + utf8.decode(bytes, allowMalformed: true), + ), + truncated: true, + ); + } finally { + await raf.close(); + } +} + +/// Falls through to the original text on parse errors. +String _maybePrettify(String content, String ext) { + if (ext != 'json') return content; + try { + final parsed = jsonDecode(content); + return const JsonEncoder.withIndent(' ').convert(parsed); + } on Object { + return content; + } } diff --git a/lib/widget/prosemirror/pm_image_view.dart b/lib/widget/prosemirror/pm_image_view.dart index 950d54d..1a347f4 100644 --- a/lib/widget/prosemirror/pm_image_view.dart +++ b/lib/widget/prosemirror/pm_image_view.dart @@ -10,9 +10,33 @@ class PmImageView extends StatelessWidget { const PmImageView({required this.node, super.key}); + /// Full-resolution source, used for the zoomable fullscreen view. + static ImageProvider? sourceProvider(PmImage node) { + final bytes = node.bytes; + if (bytes != null) return MemoryImage(bytes); + if (node.src.startsWith('http')) { + return CachedNetworkImageProvider(node.src); + } + return null; + } + + /// Inline images never render wider than the screen, so they are decoded at + /// most at its physical width instead of the (often camera-sized) original. + /// [PmJsonView] precaches through this too, so both hit the same cache key. + static ImageProvider? inlineProvider(BuildContext context, PmImage node) { + final source = sourceProvider(node); + if (source == null) return null; + final width = + (MediaQuery.sizeOf(context).width * + MediaQuery.devicePixelRatioOf(context)) + .round(); + return ResizeImage(source, width: width); + } + @override Widget build(BuildContext context) { - final provider = _imageProvider(); + final provider = sourceProvider(node); + final inline = inlineProvider(context, node); return LayoutBuilder( builder: (context, constraints) { final available = constraints.maxWidth.isFinite @@ -22,7 +46,13 @@ class PmImageView extends StatelessWidget { Widget image = ConstrainedBox( constraints: BoxConstraints(maxWidth: targetWidth ?? available), - child: _imageWidget(context, provider), + child: inline == null + ? _brokenImage(context) + : Image( + image: inline, + errorBuilder: (context, error, stack) => + _brokenImage(context), + ), ); final href = node.href; @@ -43,30 +73,6 @@ class PmImageView extends StatelessWidget { ); } - ImageProvider? _imageProvider() { - if (node.bytes != null) return MemoryImage(node.bytes!); - if (node.src.startsWith('http')) { - return CachedNetworkImageProvider(node.src); - } - return null; - } - - Widget _imageWidget(BuildContext context, ImageProvider? provider) { - if (node.bytes != null) { - return Image.memory( - node.bytes!, - errorBuilder: (context, error, stack) => _brokenImage(context), - ); - } - if (node.src.startsWith('http')) { - return CachedNetworkImage( - imageUrl: node.src, - errorWidget: (context, url, error) => _brokenImage(context), - ); - } - return _brokenImage(context); - } - Widget _brokenImage(BuildContext context) { final theme = Theme.of(context); return DecoratedBox( diff --git a/lib/widget/prosemirror/pm_json_view.dart b/lib/widget/prosemirror/pm_json_view.dart index d17bfeb..077171c 100644 --- a/lib/widget/prosemirror/pm_json_view.dart +++ b/lib/widget/prosemirror/pm_json_view.dart @@ -1,9 +1,9 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../app_progress_indicator.dart'; import 'pm_document_view.dart'; +import 'pm_image_view.dart'; import 'pm_node.dart'; /// Renders raw ProseMirror JSON and memoises the parsed tree across rebuilds. @@ -31,7 +31,7 @@ class PmJsonView extends StatefulWidget { class _PmJsonViewState extends State { PmNode? _shown; PmNode? _pending; - List _pendingProviders = const []; + List _pendingImages = const []; bool _precacheStarted = false; int _generation = 0; @@ -62,17 +62,17 @@ class _PmJsonViewState extends State { void _parse() { final doc = PmNode.fromJson(widget.json); - final providers = []; - _collectProviders(doc, providers); + final images = []; + _collectImages(doc, images); _generation++; _precacheStarted = false; - if (providers.isEmpty) { + if (images.isEmpty) { _shown = doc; _pending = null; - _pendingProviders = const []; + _pendingImages = const []; } else { _pending = doc; - _pendingProviders = providers; + _pendingImages = images; } } @@ -82,8 +82,9 @@ class _PmJsonViewState extends State { _precacheStarted = true; final generation = _generation; Future.wait([ - for (final provider in _pendingProviders) - precacheImage(provider, context, onError: (_, _) {}), + for (final image in _pendingImages) + if (PmImageView.inlineProvider(context, image) case final provider?) + precacheImage(provider, context, onError: (_, _) {}), ]) .timeout(PmJsonView.precacheTimeout, onTimeout: () => const []) .whenComplete(() { @@ -91,23 +92,16 @@ class _PmJsonViewState extends State { setState(() { _shown = pending; _pending = null; - _pendingProviders = const []; + _pendingImages = const []; }); } }); } - void _collectProviders(PmNode node, List out) { - if (node is PmImage) { - final bytes = node.bytes; - if (bytes != null) { - out.add(MemoryImage(bytes)); - } else if (node.src.startsWith('http')) { - out.add(CachedNetworkImageProvider(node.src)); - } - } + void _collectImages(PmNode node, List out) { + if (node is PmImage) out.add(node); for (final child in node.children) { - _collectProviders(child, out); + _collectImages(child, out); } } diff --git a/lib/widget/share_position_origin.dart b/lib/widget/share_position_origin.dart index 2086f09..9a27a61 100644 --- a/lib/widget/share_position_origin.dart +++ b/lib/widget/share_position_origin.dart @@ -4,7 +4,7 @@ class SharePositionOrigin { static Rect get(BuildContext context) => Rect.fromLTWH( 0, 0, - MediaQuery.of(context).size.width, - MediaQuery.of(context).size.height / 2, + MediaQuery.sizeOf(context).width, + MediaQuery.sizeOf(context).height / 2, ); } diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart index a76d0d2..f9f0f29 100644 --- a/lib/widget/user_avatar.dart +++ b/lib/widget/user_avatar.dart @@ -162,8 +162,8 @@ Future _fetchAvatarPayload(String url) async { () => http.get( Uri.parse(url), headers: { - // User avatars are public in Nextcloud, so the account switcher can - // show them while a guardian (no Nextcloud identity) is active. + // User avatars are public in Nextcloud, so they also load while the + // active session has no Nextcloud identity. ...?SessionManager().current?.nextcloud?.authHeaders, 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', }, @@ -390,6 +390,10 @@ class _UserAvatarState extends State { payload.bytes, width: radius * 2, height: radius * 2, + // Group avatars arrive at a fixed large server size; decoding them + // at display size keeps a chat list from filling the image cache. + cacheWidth: (radius * 2 * MediaQuery.devicePixelRatioOf(context)) + .round(), fit: BoxFit.cover, gaplessPlayback: true, ); diff --git a/lib/widget_data/widget_data.dart b/lib/widget_data/widget_data.dart index 8ccd1a4..af0704d 100644 --- a/lib/widget_data/widget_data.dart +++ b/lib/widget_data/widget_data.dart @@ -14,9 +14,6 @@ enum WidgetLessonStatus { irregular, teacherChanged, event, - // Sonder-Lesson-Types (Aufsicht, Sprechstunde, …) — Native-Widget-Renderer, - // die den Status noch nicht kennen, fallen still auf den regulären Stil - // zurück, sobald der String dort unbekannt ist. duty, } @@ -35,6 +32,9 @@ abstract class WidgetLesson with _$WidgetLesson { String? originalTeacher, required WidgetLessonStatus status, String? customColor, + /// Fertig aufgelöste Kachelfarbe als `#RRGGBB`. Native Renderer kennen die + /// Paletten nicht — Änderungen daran brauchen so keinen nativen Eingriff. + String? color, @Default(0) int siblingCount, }) = _WidgetLesson; diff --git a/lib/widget_data/widget_data.freezed.dart b/lib/widget_data/widget_data.freezed.dart index 08f085c..0b01dda 100644 --- a/lib/widget_data/widget_data.freezed.dart +++ b/lib/widget_data/widget_data.freezed.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // coverage:ignore-file -// ignore_for_file: type=lint +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark part of 'widget_data.dart'; @@ -9,6 +9,7 @@ part of 'widget_data.dart'; // FreezedGenerator // ************************************************************************** +// GENERATED CODE - DO NOT MODIFY BY HAND // dart format off T _$identity(T value) => value; @@ -18,7 +19,9 @@ mixin _$WidgetLesson { DateTime get start; DateTime get end; String get subjectShort; String? get subjectLong; String? get room;/// On teacher accounts this carries the class label ("7a") instead of the /// teacher short name — see `WidgetDataMapper` `showClassInsteadOfTeacher`; /// [originalTeacher] is null in that case. - String? get teacher; String? get originalTeacher; WidgetLessonStatus get status; String? get customColor; int get siblingCount; + String? get teacher; String? get originalTeacher; WidgetLessonStatus get status; String? get customColor;/// Fertig aufgelöste Kachelfarbe als `#RRGGBB`. Native Renderer kennen die +/// Paletten nicht — Änderungen daran brauchen so keinen nativen Eingriff. + String? get color; int get siblingCount; /// Create a copy of WidgetLesson /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -31,16 +34,21 @@ $WidgetLessonCopyWith get copyWith => _$WidgetLessonCopyWithImpl Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,siblingCount); +int get hashCode { + final _this = this as WidgetLesson; + return Object.hash(runtimeType,_this.start,_this.end,_this.subjectShort,_this.subjectLong,_this.room,_this.teacher,_this.originalTeacher,_this.status,_this.customColor,_this.color,_this.siblingCount); +} @override String toString() { - return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, siblingCount: $siblingCount)'; + final _this = this as WidgetLesson; + return 'WidgetLesson(start: ${_this.start}, end: ${_this.end}, subjectShort: ${_this.subjectShort}, subjectLong: ${_this.subjectLong}, room: ${_this.room}, teacher: ${_this.teacher}, originalTeacher: ${_this.originalTeacher}, status: ${_this.status}, customColor: ${_this.customColor}, color: ${_this.color}, siblingCount: ${_this.siblingCount})'; } @@ -51,7 +59,7 @@ abstract mixin class $WidgetLessonCopyWith<$Res> { factory $WidgetLessonCopyWith(WidgetLesson value, $Res Function(WidgetLesson) _then) = _$WidgetLessonCopyWithImpl; @useResult $Res call({ - DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount + DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount }); @@ -68,8 +76,8 @@ class _$WidgetLessonCopyWithImpl<$Res> /// Create a copy of WidgetLesson /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? siblingCount = null,}) { - return _then(_self.copyWith( +@pragma('vm:prefer-inline') @override $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? color = freezed,Object? siblingCount = null,}) { + return _then(WidgetLesson( start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable as DateTime,subjectShort: null == subjectShort ? _self.subjectShort : subjectShort // ignore: cast_nullable_to_non_nullable @@ -79,6 +87,7 @@ as String?,teacher: freezed == teacher ? _self.teacher : teacher // ignore: cast as String?,originalTeacher: freezed == originalTeacher ? _self.originalTeacher : originalTeacher // ignore: cast_nullable_to_non_nullable as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as WidgetLessonStatus,customColor: freezed == customColor ? _self.customColor : customColor // ignore: cast_nullable_to_non_nullable +as String?,color: freezed == color ? _self.color : color // ignore: cast_nullable_to_non_nullable as String?,siblingCount: null == siblingCount ? _self.siblingCount : siblingCount // ignore: cast_nullable_to_non_nullable as int, )); @@ -165,10 +174,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _WidgetLesson() when $default != null: -return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _: +return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.color,_that.siblingCount);case _: return orElse(); } @@ -186,10 +195,10 @@ return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount) $default,) {final _that = this; switch (_that) { case _WidgetLesson(): -return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _: +return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.color,_that.siblingCount);case _: throw StateError('Unexpected subclass'); } @@ -206,10 +215,10 @@ return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount)? $default,) {final _that = this; switch (_that) { case _WidgetLesson() when $default != null: -return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _: +return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.color,_that.siblingCount);case _: return null; } @@ -221,7 +230,7 @@ return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that @JsonSerializable() class _WidgetLesson implements WidgetLesson { - const _WidgetLesson({required this.start, required this.end, required this.subjectShort, this.subjectLong, this.room, this.teacher, this.originalTeacher, required this.status, this.customColor, this.siblingCount = 0}); + const _WidgetLesson({required this.start, required this.end, required this.subjectShort, this.subjectLong, this.room, this.teacher, this.originalTeacher, required this.status, this.customColor, this.color, this.siblingCount = 0}); factory _WidgetLesson.fromJson(Map json) => _$WidgetLessonFromJson(json); @override final DateTime start; @@ -236,6 +245,9 @@ class _WidgetLesson implements WidgetLesson { @override final String? originalTeacher; @override final WidgetLessonStatus status; @override final String? customColor; +/// Fertig aufgelöste Kachelfarbe als `#RRGGBB`. Native Renderer kennen die +/// Paletten nicht — Änderungen daran brauchen so keinen nativen Eingriff. +@override final String? color; @override@JsonKey() final int siblingCount; /// Create a copy of WidgetLesson @@ -251,16 +263,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetLesson&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.subjectShort, subjectShort) || other.subjectShort == subjectShort)&&(identical(other.subjectLong, subjectLong) || other.subjectLong == subjectLong)&&(identical(other.room, room) || other.room == room)&&(identical(other.teacher, teacher) || other.teacher == teacher)&&(identical(other.originalTeacher, originalTeacher) || other.originalTeacher == originalTeacher)&&(identical(other.status, status) || other.status == status)&&(identical(other.customColor, customColor) || other.customColor == customColor)&&(identical(other.siblingCount, siblingCount) || other.siblingCount == siblingCount)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetLesson&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.subjectShort, subjectShort) || other.subjectShort == subjectShort)&&(identical(other.subjectLong, subjectLong) || other.subjectLong == subjectLong)&&(identical(other.room, room) || other.room == room)&&(identical(other.teacher, teacher) || other.teacher == teacher)&&(identical(other.originalTeacher, originalTeacher) || other.originalTeacher == originalTeacher)&&(identical(other.status, status) || other.status == status)&&(identical(other.customColor, customColor) || other.customColor == customColor)&&(identical(other.color, color) || other.color == color)&&(identical(other.siblingCount, siblingCount) || other.siblingCount == siblingCount)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,siblingCount); +int get hashCode { + return Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,color,siblingCount); +} @override String toString() { - return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, siblingCount: $siblingCount)'; + return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, color: $color, siblingCount: $siblingCount)'; } @@ -271,7 +285,7 @@ abstract mixin class _$WidgetLessonCopyWith<$Res> implements $WidgetLessonCopyWi factory _$WidgetLessonCopyWith(_WidgetLesson value, $Res Function(_WidgetLesson) _then) = __$WidgetLessonCopyWithImpl; @override @useResult $Res call({ - DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount + DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount }); @@ -288,7 +302,7 @@ class __$WidgetLessonCopyWithImpl<$Res> /// Create a copy of WidgetLesson /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? siblingCount = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? color = freezed,Object? siblingCount = null,}) { return _then(_WidgetLesson( start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable @@ -299,6 +313,7 @@ as String?,teacher: freezed == teacher ? _self.teacher : teacher // ignore: cast as String?,originalTeacher: freezed == originalTeacher ? _self.originalTeacher : originalTeacher // ignore: cast_nullable_to_non_nullable as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as WidgetLessonStatus,customColor: freezed == customColor ? _self.customColor : customColor // ignore: cast_nullable_to_non_nullable +as String?,color: freezed == color ? _self.color : color // ignore: cast_nullable_to_non_nullable as String?,siblingCount: null == siblingCount ? _self.siblingCount : siblingCount // ignore: cast_nullable_to_non_nullable as int, )); @@ -332,16 +347,21 @@ $WidgetPeriodCopyWith get copyWith => _$WidgetPeriodCopyWithImpl Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes); +int get hashCode { + final _this = this as WidgetPeriod; + return Object.hash(runtimeType,_this.name,_this.startMinutes,_this.endMinutes,_this.virtualStartMinutes,_this.virtualEndMinutes); +} @override String toString() { - return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)'; + final _this = this as WidgetPeriod; + return 'WidgetPeriod(name: ${_this.name}, startMinutes: ${_this.startMinutes}, endMinutes: ${_this.endMinutes}, virtualStartMinutes: ${_this.virtualStartMinutes}, virtualEndMinutes: ${_this.virtualEndMinutes})'; } @@ -370,7 +390,7 @@ class _$WidgetPeriodCopyWithImpl<$Res> /// Create a copy of WidgetPeriod /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? startMinutes = null,Object? endMinutes = null,Object? virtualStartMinutes = null,Object? virtualEndMinutes = null,}) { - return _then(_self.copyWith( + return _then(WidgetPeriod( name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,startMinutes: null == startMinutes ? _self.startMinutes : startMinutes // ignore: cast_nullable_to_non_nullable as int,endMinutes: null == endMinutes ? _self.endMinutes : endMinutes // ignore: cast_nullable_to_non_nullable @@ -547,16 +567,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetPeriod&&(identical(other.name, name) || other.name == name)&&(identical(other.startMinutes, startMinutes) || other.startMinutes == startMinutes)&&(identical(other.endMinutes, endMinutes) || other.endMinutes == endMinutes)&&(identical(other.virtualStartMinutes, virtualStartMinutes) || other.virtualStartMinutes == virtualStartMinutes)&&(identical(other.virtualEndMinutes, virtualEndMinutes) || other.virtualEndMinutes == virtualEndMinutes)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetPeriod&&(identical(other.name, name) || other.name == name)&&(identical(other.startMinutes, startMinutes) || other.startMinutes == startMinutes)&&(identical(other.endMinutes, endMinutes) || other.endMinutes == endMinutes)&&(identical(other.virtualStartMinutes, virtualStartMinutes) || other.virtualStartMinutes == virtualStartMinutes)&&(identical(other.virtualEndMinutes, virtualEndMinutes) || other.virtualEndMinutes == virtualEndMinutes)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes); +int get hashCode { + return Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes); +} @override String toString() { - return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)'; + return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)'; } @@ -615,16 +637,21 @@ $WidgetDayInfoCopyWith get copyWith => _$WidgetDayInfoCopyWithImp @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetDayInfo&&(identical(other.date, date) || other.date == date)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)); + final _this = this as WidgetDayInfo; + return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetDayInfo&&(identical(other.date, _this.date) || other.date == _this.date)&&(identical(other.isHoliday, _this.isHoliday) || other.isHoliday == _this.isHoliday)&&(identical(other.holidayName, _this.holidayName) || other.holidayName == _this.holidayName)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,date,isHoliday,holidayName); +int get hashCode { + final _this = this as WidgetDayInfo; + return Object.hash(runtimeType,_this.date,_this.isHoliday,_this.holidayName); +} @override String toString() { - return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)'; + final _this = this as WidgetDayInfo; + return 'WidgetDayInfo(date: ${_this.date}, isHoliday: ${_this.isHoliday}, holidayName: ${_this.holidayName})'; } @@ -653,7 +680,7 @@ class _$WidgetDayInfoCopyWithImpl<$Res> /// Create a copy of WidgetDayInfo /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({Object? date = null,Object? isHoliday = null,Object? holidayName = freezed,}) { - return _then(_self.copyWith( + return _then(WidgetDayInfo( date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable as DateTime,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable as bool,holidayName: freezed == holidayName ? _self.holidayName : holidayName // ignore: cast_nullable_to_non_nullable @@ -818,16 +845,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetDayInfo&&(identical(other.date, date) || other.date == date)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetDayInfo&&(identical(other.date, date) || other.date == date)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,date,isHoliday,holidayName); +int get hashCode { + return Object.hash(runtimeType,date,isHoliday,holidayName); +} @override String toString() { - return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)'; + return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)'; } @@ -888,16 +917,21 @@ $WidgetTimetableDataCopyWith get copyWith => _$WidgetTimeta @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other.lessons, lessons)&&const DeepCollectionEquality().equals(other.periods, periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other.days, days)); + final _this = this as WidgetTimetableData; + return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetTimetableData&&(identical(other.fetchedAt, _this.fetchedAt) || other.fetchedAt == _this.fetchedAt)&&(identical(other.anchorDate, _this.anchorDate) || other.anchorDate == _this.anchorDate)&&const DeepCollectionEquality().equals(other.lessons, _this.lessons)&&const DeepCollectionEquality().equals(other.periods, _this.periods)&&(identical(other.isHoliday, _this.isHoliday) || other.isHoliday == _this.isHoliday)&&(identical(other.holidayName, _this.holidayName) || other.holidayName == _this.holidayName)&&const DeepCollectionEquality().equals(other.days, _this.days)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(lessons),const DeepCollectionEquality().hash(periods),isHoliday,holidayName,const DeepCollectionEquality().hash(days)); +int get hashCode { + final _this = this as WidgetTimetableData; + return Object.hash(runtimeType,_this.fetchedAt,_this.anchorDate,const DeepCollectionEquality().hash(_this.lessons),const DeepCollectionEquality().hash(_this.periods),_this.isHoliday,_this.holidayName,const DeepCollectionEquality().hash(_this.days)); +} @override String toString() { - return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)'; + final _this = this as WidgetTimetableData; + return 'WidgetTimetableData(fetchedAt: ${_this.fetchedAt}, anchorDate: ${_this.anchorDate}, lessons: ${_this.lessons}, periods: ${_this.periods}, isHoliday: ${_this.isHoliday}, holidayName: ${_this.holidayName}, days: ${_this.days})'; } @@ -926,7 +960,7 @@ class _$WidgetTimetableDataCopyWithImpl<$Res> /// Create a copy of WidgetTimetableData /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,Object? days = null,}) { - return _then(_self.copyWith( + return _then(WidgetTimetableData( fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable as DateTime,anchorDate: null == anchorDate ? _self.anchorDate : anchorDate // ignore: cast_nullable_to_non_nullable as DateTime,lessons: null == lessons ? _self.lessons : lessons // ignore: cast_nullable_to_non_nullable @@ -1075,7 +1109,7 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th @JsonSerializable() class _WidgetTimetableData implements WidgetTimetableData { - const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required final List lessons, final List periods = const [], this.isHoliday = false, this.holidayName, final List days = const []}): _lessons = lessons,_periods = periods,_days = days; + const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required List lessons, List periods = const [], this.isHoliday = false, this.holidayName, List days = const []}): _lessons = lessons,_periods = periods,_days = days; factory _WidgetTimetableData.fromJson(Map json) => _$WidgetTimetableDataFromJson(json); @override final DateTime fetchedAt; @@ -1122,16 +1156,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other._lessons, _lessons)&&const DeepCollectionEquality().equals(other._periods, _periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other._days, _days)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other.lessons, _lessons)&&const DeepCollectionEquality().equals(other.periods, _periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other.days, _days)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName,const DeepCollectionEquality().hash(_days)); +int get hashCode { + return Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName,const DeepCollectionEquality().hash(_days)); +} @override String toString() { - return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)'; + return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)'; } diff --git a/lib/widget_data/widget_data.g.dart b/lib/widget_data/widget_data.g.dart index 6a836c3..de71751 100644 --- a/lib/widget_data/widget_data.g.dart +++ b/lib/widget_data/widget_data.g.dart @@ -17,6 +17,7 @@ _WidgetLesson _$WidgetLessonFromJson(Map json) => originalTeacher: json['originalTeacher'] as String?, status: $enumDecode(_$WidgetLessonStatusEnumMap, json['status']), customColor: json['customColor'] as String?, + color: json['color'] as String?, siblingCount: (json['siblingCount'] as num?)?.toInt() ?? 0, ); @@ -31,6 +32,7 @@ Map _$WidgetLessonToJson(_WidgetLesson instance) => 'originalTeacher': instance.originalTeacher, 'status': _$WidgetLessonStatusEnumMap[instance.status]!, 'customColor': instance.customColor, + 'color': instance.color, 'siblingCount': instance.siblingCount, }; diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart index af8a063..79a37dd 100644 --- a/lib/widget_data/widget_data_mapper.dart +++ b/lib/widget_data/widget_data_mapper.dart @@ -1,6 +1,5 @@ import 'dart:developer'; - -import 'package:rrule/rrule.dart'; +import 'dart:ui'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; @@ -10,10 +9,14 @@ import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week_res import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../extensions/date_time.dart'; +import '../utils/recurrence_occurrences.dart'; +import '../view/pages/timetable/data/lesson_color.dart'; import '../view/pages/timetable/data/lesson_labels.dart'; import '../view/pages/timetable/data/lesson_merger.dart'; import '../view/pages/timetable/data/lesson_period_schedule.dart'; import '../view/pages/timetable/data/lesson_status.dart'; +import '../view/pages/timetable/data/lesson_type_label.dart'; +import '../view/pages/timetable/data/subject_color_palette.dart'; import 'widget_data.dart'; class WidgetDataMapper { @@ -322,20 +325,26 @@ class WidgetDataMapper { }) { final start = lesson.startDateTime; final end = lesson.endDateTime; - final status = _mapStatus( - LessonStatusClassifier.classify(lesson, start, end, now), - ); final subjectShortRaw = lesson.subjects.firstOrNull?.trim() ?? ''; - // Webuntis sometimes ships subject-less entries (Wandertag etc.). Fall - // back to "Event" so the tile isn't just a dash. - final subjectShort = subjectShortRaw.isEmpty ? 'Event' : subjectShortRaw; - String? subjectLong; - if (subjects != null && subjectShortRaw.isNotEmpty) { - subjectLong = subjects.result - .where((s) => s.shortName == subjectShortRaw) - .firstOrNull - ?.longName; - } + // Gleiche Einstufung wie TimetableAppointmentFactory, damit Aufsicht, + // Prüfung & Co. im Widget dieselbe Farbe tragen wie in der App. + final lessonStatus = LessonStatusClassifier.classify( + lesson, + start, + end, + now, + isEvent: subjectShortRaw.isEmpty && lesson.lessonType == 'LESSON', + isDuty: lesson.lessonType != 'LESSON', + ); + final subjectShort = subjectShortRaw.isEmpty + ? LessonTypeLabel.forEntry(lesson) + : subjectShortRaw; + final subject = subjects == null || subjectShortRaw.isEmpty + ? null + : subjects.result + .where((s) => s.shortName == subjectShortRaw) + .firstOrNull; + final subjectLong = subject?.longName; final roomShort = lesson.rooms.firstOrNull; var roomName = roomShort; if (rooms != null && roomShort != null) { @@ -347,11 +356,12 @@ class WidgetDataMapper { } final teacher = lesson.teachers.firstOrNull; // Lehrerpläne: Klasse in den Teacher-Slot mappen, damit die nativen - // Renderer unverändert bleiben. Klassenlose Einträge (Aufsichten) behalten - // den Lehrer als Fallback. - final classLabel = showClassInsteadOfTeacher ? lesson.classLabel : null; - final teacherName = classLabel ?? teacher?.shortName; - final originalTeacher = classLabel != null + // Renderer unverändert bleiben. Klassenlose Einträge (Aufsichten) lassen + // den Slot leer — die Lehrkraft wäre die Plan-Inhaberin selbst. + final teacherName = showClassInsteadOfTeacher + ? lesson.classLabel + : teacher?.shortName; + final originalTeacher = showClassInsteadOfTeacher ? null : teacher?.originalShortName; return WidgetLesson( @@ -362,10 +372,29 @@ class WidgetDataMapper { room: roomName, teacher: teacherName, originalTeacher: originalTeacher, - status: status, + status: _mapStatus(lessonStatus), + color: _hex(_lessonColor(lessonStatus, subject?.color)), ); } + /// Das Widget kennt keine Zeitzustände: laufende Stunden bekommen dieselbe + /// Farbe wie reguläre, sonst würde die Hervorhebung zwischen zwei + /// Aktualisierungen veralten. + static Color _lessonColor(LessonStatus status, String? subjectColor) { + final isRegular = + status == LessonStatus.regular || + status == LessonStatus.ongoing || + status == LessonStatus.past; + if (!isRegular) return LessonColor.forStatus(status); + return SubjectColorPalette.colorFromName(subjectColor) ?? + LessonColor.regular; + } + + static String _hex(Color color) { + final rgb = color.toARGB32() & 0xFFFFFF; + return '#${rgb.toRadixString(16).padLeft(6, '0').toUpperCase()}'; + } + static WidgetLessonStatus _mapStatus(LessonStatus status) { switch (status) { case LessonStatus.cancelled: @@ -439,11 +468,13 @@ class WidgetDataMapper { } try { - final parsed = RecurrenceRule.fromString(rule); final anchorUtc = event.startDate.toUtc(); - for (final occUtc in parsed.getInstances(start: anchorUtc)) { - if (!occUtc.isBefore(rangeEndUtc)) break; - if (occUtc.isBefore(rangeStartUtc)) continue; + for (final occUtc in RecurrenceOccurrences.between( + rule, + anchorUtc, + rangeStartUtc, + rangeEndUtc, + )) { final occLocal = occUtc.toLocal(); final occStart = DateTime( occLocal.year, @@ -475,6 +506,12 @@ class WidgetDataMapper { subjectLong: title.isEmpty ? null : title, status: WidgetLessonStatus.event, customColor: event.color, + color: _hex( + SubjectColorPalette.colorFromName(event.color) ?? + SubjectColorPalette.displayOf( + SubjectColorPalette.defaultColor, + ).color, + ), ); final isAllDay = duration == Duration.zero && _isMidnight(event.startDate); diff --git a/lib/widget_data/widget_publisher.dart b/lib/widget_data/widget_publisher.dart index 8eed48b..699e46e 100644 --- a/lib/widget_data/widget_publisher.dart +++ b/lib/widget_data/widget_publisher.dart @@ -1,8 +1,10 @@ +import 'dart:convert'; import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../session/session_manager.dart'; import '../state/app/modules/timetable/bloc/timetable_state.dart'; import '../state/app/modules/timetable/subject/timetable_subject.dart'; import '../storage/settings.dart'; @@ -20,23 +22,75 @@ class WidgetPublisher { static DateTime widgetNow() => kDebugMode ? DateTime.now().add(debugTimeShift) : DateTime.now(); + /// Identical snapshots are still re-written after this long, so the stored + /// `fetchedAt` (freshness for the background refresh) doesn't go stale. + static const Duration _republishAfter = Duration(minutes: 10); + + static Future _queue = Future.value(); + static (bool, String, bool, String?)? _lastFlags; + static String? _lastSignature; + static DateTime? _lastPublishedAt; + + /// Forgets what was written last, so the next publish writes again. Called + /// whenever the stored widget data is cleared (sign-out). + static void resetDedupe() { + _lastFlags = null; + _lastSignature = null; + _lastPublishedAt = null; + } + + /// Publishes run one at a time: they are fire-and-forget from the bloc + /// stream, and interleaved writes could leave day and week data from + /// different states behind. + /// [epoch] is the session the [state] belongs to; defaults to the current + /// one. Callers that delay the publish must capture it up front. static Future publishFromBlocState( TimetableState state, { required TimetableSubject subject, Settings? settings, bool showClassInsteadOfTeacher = false, + int? epoch, + }) { + final sessionEpoch = epoch ?? SessionManager().sessionEpoch; + return _queue = _queue.then( + (_) => _publish( + state, + subject: subject, + settings: settings, + showClassInsteadOfTeacher: showClassInsteadOfTeacher, + epoch: sessionEpoch, + ), + ); + } + + static Future _publish( + TimetableState state, { + required TimetableSubject subject, + required Settings? settings, + required bool showClassInsteadOfTeacher, + required int epoch, }) async { + if (!SessionManager().isCurrentSession(epoch)) return; try { final connectDouble = settings?.timetableSettings.connectDoubleLessons ?? true; // Mirror into widget storage so the background isolate sees the same // values the user just toggled — concurrently, they are independent. - await Future.wait([ - WidgetSync.setConnectDoubleLessons(connectDouble), - WidgetSync.setThemeMode(_themeName(settings?.appTheme)), - WidgetSync.setShowClassInsteadOfTeacher(showClassInsteadOfTeacher), - WidgetSync.setSubject(subject), - ]); + final flags = ( + connectDouble, + _themeName(settings?.appTheme), + showClassInsteadOfTeacher, + WidgetSync.encodeSubject(subject), + ); + if (flags != _lastFlags) { + await Future.wait([ + WidgetSync.setConnectDoubleLessons(flags.$1), + WidgetSync.setThemeMode(flags.$2), + WidgetSync.setShowClassInsteadOfTeacher(flags.$3), + WidgetSync.setSubject(subject), + ]); + _lastFlags = flags; + } final lessons = state.getAllKnownLessons(); final now = widgetNow(); final dayData = WidgetDataMapper.buildDayData( @@ -61,6 +115,23 @@ class WidgetPublisher { connectDoubleLessons: connectDouble, showClassInsteadOfTeacher: showClassInsteadOfTeacher, ); + // A publish still running at sign-out would put the previous account's + // plan back onto the just cleared widget. + if (!SessionManager().isCurrentSession(epoch)) return; + // Most bloc emits (week swipes, prefetches) don't touch the widget's + // window; skip the SharedPreferences commits and widget re-render then. + final signature = jsonEncode([ + _withoutFetchedAt(dayData.toJson()), + _withoutFetchedAt(weekData.toJson()), + ]); + final lastAt = _lastPublishedAt; + if (signature == _lastSignature && + lastAt != null && + now.difference(lastAt) < _republishAfter) { + return; + } + _lastSignature = signature; + _lastPublishedAt = now; await WidgetSync.writeDayData(dayData); await WidgetSync.writeWeekData(weekData); await WidgetSync.setLoggedIn(true); @@ -72,6 +143,9 @@ class WidgetPublisher { } } + static Map _withoutFetchedAt(Map json) => + Map.of(json)..remove('fetchedAt'); + static String _themeName(ThemeMode? mode) { switch (mode) { case ThemeMode.light: diff --git a/lib/widget_data/widget_sync.dart b/lib/widget_data/widget_sync.dart index efcff01..9a3aa1f 100644 --- a/lib/widget_data/widget_sync.dart +++ b/lib/widget_data/widget_sync.dart @@ -6,6 +6,7 @@ import 'package:home_widget/home_widget.dart'; import '../state/app/modules/timetable/subject/timetable_subject.dart'; import 'widget_data.dart'; +import 'widget_publisher.dart'; /// Bridge to the native widget host. All keys/names live here so the Kotlin /// and Swift sides stay in sync. @@ -146,6 +147,7 @@ class WidgetSync { } static Future clear() async { + WidgetPublisher.resetDedupe(); await ensureInitialized(); await HomeWidget.saveWidgetData(dayDataKey, null); await HomeWidget.saveWidgetData(weekDataKey, null); diff --git a/pubspec.yaml b/pubspec.yaml index df42855..91dabc8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,9 @@ dependencies: emoji_picker_flutter: ^4.3.0 file_picker: ^11.0.2 filesize: ^2.0.1 + # Disk cache behind cached_network_image; used directly by the dev tools to + # size and empty it. Already present transitively. + flutter_cache_manager: ^3.4.1 firebase_core: ^4.1.0 firebase_messaging: ^16.0.1 flutter_app_badge: ^2.0.2 diff --git a/test/api/cache_store_test.dart b/test/api/cache_store_test.dart new file mode 100644 index 0000000..bad0123 --- /dev/null +++ b/test/api/cache_store_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/cache_store.dart'; + +void main() { + group('CacheStore.parse', () { + test('splits the timestamp header from the payload', () { + final entry = CacheStore.parse('1700000000000\n{"a":"b\\nc"}'); + expect(entry!.lastUpdate, 1700000000000); + expect(entry.json, '{"a":"b\\nc"}'); + }); + + test('keeps newlines inside the payload', () { + expect(CacheStore.parse('1\nline1\nline2')!.json, 'line1\nline2'); + }); + + test('rejects files without a valid header', () { + expect(CacheStore.parse('{"a":1}'), isNull); + expect(CacheStore.parse('abc\n{}'), isNull); + }); + }); + + group('CacheStore.directoryName', () { + test('keeps the original directory for the pre-multi-account account', () { + expect(CacheStore.directoryName(''), 'request_cache'); + }); + + test('separates accounts and the signed-out state', () { + final names = { + CacheStore.directoryName(''), + CacheStore.directoryName('a1b2'), + CacheStore.directoryName('c3d4'), + CacheStore.directoryName(null), + }; + expect(names, hasLength(4)); + }); + }); +} diff --git a/test/extensions/date_time_test.dart b/test/extensions/date_time_test.dart index c1eb7be..cc1ac34 100644 --- a/test/extensions/date_time_test.dart +++ b/test/extensions/date_time_test.dart @@ -86,6 +86,15 @@ void main() { final d = DateTime(2026, 5, 18, 13, 45); expect(d.subtractDays(5), d.addDays(-5)); }); + + test('dateOnly drops the time of day', () { + expect(DateTime(2026, 9, 24, 13, 45).dateOnly, DateTime(2026, 9, 24)); + }); + + test('mondayOfWeek returns Monday midnight for any weekday', () { + expect(DateTime(2026, 9, 21).mondayOfWeek, DateTime(2026, 9, 21)); + expect(DateTime(2026, 9, 27, 23, 59).mondayOfWeek, DateTime(2026, 9, 21)); + }); }); group('DateTimeFormatting', () { diff --git a/test/push/push_permission_test.dart b/test/push/push_permission_test.dart index 8ec6fc7..d029751 100644 --- a/test/push/push_permission_test.dart +++ b/test/push/push_permission_test.dart @@ -1,5 +1,6 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'package:marianum_mobile/push/push_registration.dart'; void main() { @@ -37,4 +38,36 @@ void main() { expect(AuthorizationStatus.values, hasLength(5)); }); }); + + group('PushRegistration.canDisplay', () { + test('only authorized and provisional show notifications', () { + expect( + AuthorizationStatus.values.where(PushRegistration.canDisplay), + unorderedEquals([ + AuthorizationStatus.authorized, + AuthorizationStatus.provisional, + ]), + ); + }); + }); + + group('osPermissionWireValue', () { + test('maps every status', () { + expect(osPermissionWireValue(AuthorizationStatus.authorized), 'granted'); + expect( + osPermissionWireValue(AuthorizationStatus.provisional), + 'provisional', + ); + expect(osPermissionWireValue(AuthorizationStatus.denied), 'denied'); + expect( + osPermissionWireValue(AuthorizationStatus.deniedPermanently), + 'denied', + ); + expect( + osPermissionWireValue(AuthorizationStatus.notDetermined), + 'notDetermined', + ); + expect(osPermissionWireValue(null), isNull); + }); + }); } diff --git a/test/share_intent/pending_share_test.dart b/test/share_intent/pending_share_test.dart index 0e88987..a5e2987 100644 --- a/test/share_intent/pending_share_test.dart +++ b/test/share_intent/pending_share_test.dart @@ -62,5 +62,18 @@ void main() { test('empty shares compare equal', () { expect(_share().contentEquals(_share()), isTrue); }); + + test('relocated share still equals its raw delivery', () { + final raw = _share(filePaths: ['/cache/a.jpg']); + final claimed = PendingShare( + filePaths: ['/cache/share_intent/1/a.jpg'], + text: null, + receivedAt: DateTime(2026, 1, 1), + sourcePaths: raw.filePaths, + ); + + expect(claimed.contentEquals(raw), isTrue); + expect(raw.contentEquals(claimed), isTrue); + }); }); } diff --git a/test/state/app_modules_order_test.dart b/test/state/app_modules_order_test.dart index 1e0f2f7..63764d5 100644 --- a/test/state/app_modules_order_test.dart +++ b/test/state/app_modules_order_test.dart @@ -114,4 +114,47 @@ void main() { expect(hidden, {Modules.talk, Modules.files}); }); }); + + group('applyTeacherDefaults', () { + test('hides the absence report once for teachers', () { + final settings = settingsWith(Modules.values); + + expect(AppModule.applyTeacherDefaults(settings, isTeacher: true), isTrue); + expect(settings.hiddenModules, [Modules.absenceReport]); + expect(settings.teacherDefaultsApplied, isTrue); + }); + + test('keeps a module the teacher re-enabled afterwards', () { + final settings = settingsWith(Modules.values); + AppModule.applyTeacherDefaults(settings, isTeacher: true); + settings.hiddenModules.remove(Modules.absenceReport); + + expect( + AppModule.applyTeacherDefaults(settings, isTeacher: true), + isFalse, + ); + expect(settings.hiddenModules, isEmpty); + }); + + test('leaves non-teachers untouched', () { + final settings = settingsWith(Modules.values); + + expect( + AppModule.applyTeacherDefaults(settings, isTeacher: false), + isFalse, + ); + expect(settings.hiddenModules, isEmpty); + expect(settings.teacherDefaultsApplied, isFalse); + }); + + test('does not duplicate an already hidden module', () { + final settings = ModulesSettings( + moduleOrder: Modules.values, + hiddenModules: [Modules.absenceReport, Modules.files], + ); + AppModule.applyTeacherDefaults(settings, isTeacher: true); + + expect(settings.hiddenModules, [Modules.absenceReport, Modules.files]); + }); + }); } diff --git a/test/state/timetable_reference_data_test.dart b/test/state/timetable_reference_data_test.dart new file mode 100644 index 0000000..4f78de7 --- /dev/null +++ b/test/state/timetable_reference_data_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state.dart'; + +TimetableGetRoomsResponse _rooms(String name) => TimetableGetRoomsResponse( + result: [McRoom(id: 1, shortName: name, longName: name)], +); + +void main() { + final base = TimetableState( + startDate: DateTime(2026, 9, 21), + endDate: DateTime(2026, 9, 25), + rooms: _rooms('A101'), + dataVersion: 3, + ); + + test('returns the same state when the content is unchanged', () { + expect(base.withReferenceData(rooms: _rooms('A101')), same(base)); + }); + + test('bumps dataVersion and takes the new value when content changed', () { + final next = _rooms('B202'); + final result = base.withReferenceData(rooms: next); + expect(result.rooms, same(next)); + expect(result.dataVersion, 4); + }); + + test('keeps unchanged instances when only another field changed', () { + final result = base.withReferenceData(rooms: _rooms('A101')); + expect(result.rooms, same(base.rooms)); + }); +} diff --git a/test/state/week_cache_test.dart b/test/state/week_cache_test.dart new file mode 100644 index 0000000..5429a42 --- /dev/null +++ b/test/state/week_cache_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import 'package:marianum_mobile/extensions/date_time.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/bloc/week_cache.dart'; + +TimetableGetWeekResponse _week(DateTime monday, {String room = 'A101'}) => + TimetableGetWeekResponse( + from: monday, + until: monday.addDays(4), + entries: [ + McTimetableEntry( + id: 1, + date: monday, + startTime: DateTime(1970, 1, 1, 8), + endTime: DateTime(1970, 1, 1, 9), + subjects: const ['ma'], + teachers: const [], + rooms: [room], + classNames: const [], + lessonType: 'LESSON', + status: 'REGULAR', + substitutionText: null, + lessonText: null, + infoText: null, + ), + ], + ); + +void main() { + final monday = DateTime(2026, 9, 21); + + test('returns null when the stored week has identical content', () { + final cache = {monday.weekKey(): _week(monday)}; + final result = mergeWeekIntoCache( + cache, + monday, + _week(monday), + viewedWeekStart: monday, + now: monday, + ); + expect(result, isNull); + }); + + test('stores changed content', () { + final cache = {monday.weekKey(): _week(monday)}; + final changed = _week(monday, room: 'B202'); + final result = mergeWeekIntoCache( + cache, + monday, + changed, + viewedWeekStart: monday, + now: monday, + ); + expect(result![monday.weekKey()], same(changed)); + }); + + test('drops weeks far from both the viewed week and today', () { + final far = monday.addDays(7 * (kWeekCacheRadius + 3)); + final nearToday = monday.addDays(-7 * kWeekCacheRadius); + final viewed = monday.addDays(7 * (kWeekCacheRadius * 3)); + final cache = { + far.weekKey(): _week(far), + nearToday.weekKey(): _week(nearToday), + }; + final result = mergeWeekIntoCache( + cache, + viewed, + _week(viewed), + viewedWeekStart: viewed, + now: monday.addDays(2), + ); + expect(result!.keys, {nearToday.weekKey(), viewed.weekKey()}); + }); +} diff --git a/test/talk/notification_level_test.dart b/test/talk/notification_level_test.dart new file mode 100644 index 0000000..234ab63 --- /dev/null +++ b/test/talk/notification_level_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/room/get_room_response.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/room/notification_level.dart'; + +typedef Level = GetRoomResponseObjectParticipantNotificationLevel; +typedef RoomType = GetRoomResponseObjectConversationType; + +GetRoomResponseObject _room(RoomType type, Level level) => + GetRoomResponseObject( + 1, + 't', + type, + 'n', + 'd', + '', + 3, + 0, + 0, + 0, + 0, + 's', + false, + false, + 0, + false, + false, + true, + 0, + false, + level, + 0, + false, + false, + 0, + 0, + GetChatResponseObject( + 1, + 't', + GetRoomResponseObjectMessageActorType.user, + 'a', + 'A', + 0, + '', + GetRoomResponseObjectMessageType.comment, + true, + 'r', + 'm', + null, + null, + null, + null, + false, + ), + null, + null, + null, + ); + +void main() { + test('Standard wird bei 1:1-Chats zu „Alle Nachrichten"', () { + final room = _room(RoomType.oneToOne, Level.defaultLevel); + expect(room.effectiveNotificationLevel, Level.alwaysNotify); + expect(room.isMuted, isFalse); + }); + + test('Standard wird bei Gruppen zu „Nur Erwähnungen"', () { + expect( + _room(RoomType.group, Level.defaultLevel).effectiveNotificationLevel, + Level.notifyOnMention, + ); + }); + + test('explizit gesetzter Level bleibt erhalten', () { + final room = _room(RoomType.oneToOne, Level.neverNotify); + expect(room.effectiveNotificationLevel, Level.neverNotify); + expect(room.isMuted, isTrue); + }); +} diff --git a/test/utils/directory_size_test.dart b/test/utils/directory_size_test.dart new file mode 100644 index 0000000..2eddb29 --- /dev/null +++ b/test/utils/directory_size_test.dart @@ -0,0 +1,22 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/utils/directory_size.dart'; + +void main() { + late Directory tmp; + + setUp(() => tmp = Directory.systemTemp.createTempSync('dir_size_test')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('sums files recursively', () async { + File('${tmp.path}/a').writeAsBytesSync(List.filled(10, 0)); + Directory('${tmp.path}/sub').createSync(); + File('${tmp.path}/sub/b').writeAsBytesSync(List.filled(5, 0)); + expect(await directorySize(tmp), 15); + }); + + test('returns 0 for a missing directory', () async { + expect(await directorySize(Directory('${tmp.path}/missing')), 0); + }); +} diff --git a/test/utils/recurrence_occurrences_test.dart b/test/utils/recurrence_occurrences_test.dart new file mode 100644 index 0000000..0b4f7fb --- /dev/null +++ b/test/utils/recurrence_occurrences_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/utils/recurrence_occurrences.dart'; + +void main() { + setUp(RecurrenceOccurrences.clear); + + final anchor = DateTime.utc(2024, 9, 2, 8); + + test('returns only occurrences inside the half-open range', () { + final hits = RecurrenceOccurrences.between( + 'RRULE:FREQ=DAILY', + anchor, + DateTime.utc(2026, 9, 21), + DateTime.utc(2026, 9, 24), + ); + expect(hits, [ + DateTime.utc(2026, 9, 21, 8), + DateTime.utc(2026, 9, 22, 8), + DateTime.utc(2026, 9, 23, 8), + ]); + }); + + test('answers earlier ranges after a later one was expanded', () { + RecurrenceOccurrences.between( + 'RRULE:FREQ=WEEKLY', + anchor, + DateTime.utc(2026, 1, 1), + DateTime.utc(2026, 1, 31), + ); + final hits = RecurrenceOccurrences.between( + 'RRULE:FREQ=WEEKLY', + anchor, + DateTime.utc(2024, 9, 1), + DateTime.utc(2024, 9, 10), + ); + expect(hits, [DateTime.utc(2024, 9, 2, 8), DateTime.utc(2024, 9, 9, 8)]); + }); + + test('stops at the end of a finite series', () { + final hits = RecurrenceOccurrences.between( + 'RRULE:FREQ=DAILY;COUNT=2', + anchor, + DateTime.utc(2024, 1, 1), + DateTime.utc(2030, 1, 1), + ); + expect(hits, hasLength(2)); + }); +} diff --git a/test/view/files/local_cache_search_test.dart b/test/view/files/local_cache_search_test.dart index c1a0868..ebb487d 100644 --- a/test/view/files/local_cache_search_test.dart +++ b/test/view/files/local_cache_search_test.dart @@ -11,13 +11,10 @@ CacheableFile _file({ bool isDirectory = false, }) => CacheableFile(path: path, isDirectory: isDirectory, name: name); -Map _doc(ListFilesResponse listing) => { - 'json': jsonEncode(listing.toJson()), - 'lastupdate': 0, -}; +String _payload(ListFilesResponse listing) => jsonEncode(listing.toJson()); void main() { - group('searchLocalCaches', () { + group('local cache index', () { final root = ListFilesResponse({ _file(path: 'Documents/', name: 'Documents', isDirectory: true), _file(path: 'Photos/', name: 'Photos', isDirectory: true), @@ -27,51 +24,45 @@ void main() { _file(path: 'Documents/Tax-Report.pdf', name: 'Tax-Report.pdf'), _file(path: 'Documents/Notes.txt', name: 'Notes.txt'), }); - final docs = { - '/MarianumMobile/wd-folder-aaa': _doc(root), - '/MarianumMobile/wd-folder-bbb': _doc(documents), - '/MarianumMobile/get-room-ccc': {'json': '{}', 'lastupdate': 0}, - }; + final index = buildLocalCacheIndex([ + _payload(root), + _payload(documents), + ]); - test('matches by name case-insensitively across all caches', () async { - final hits = await searchLocalCaches('report', docs: docs); + test('matches by name case-insensitively across all caches', () { + final hits = searchLocalCacheIndex(index, 'report'); final paths = hits.map((f) => f.path).toSet(); expect(paths, {'Reports.pdf', 'Documents/Tax-Report.pdf'}); }); - test('returns empty list for empty query', () async { - expect(await searchLocalCaches(' ', docs: docs), isEmpty); + test('returns empty list for empty query', () { + expect(searchLocalCacheIndex(index, ' '), isEmpty); }); - test('respects pathScope prefix', () async { - final hits = await searchLocalCaches( + test('respects pathScope prefix', () { + final hits = searchLocalCacheIndex( + index, 'report', pathScope: ['Documents'], - docs: docs, ); expect(hits.map((f) => f.path), ['Documents/Tax-Report.pdf']); }); - test('ignores non-folder cache documents', () async { - final hits = await searchLocalCaches('anything', docs: docs); - // Only documents starting with `wd-folder-` are scanned. The unrelated - // `get-room-ccc` doc must not crash the helper. - expect(hits, isEmpty); + test('skips payloads that are not folder listings', () { + expect(buildLocalCacheIndex(['not json', '{"foo": 1}']), isEmpty); }); - test('deduplicates entries that appear in multiple cached folders', - () async { + test('deduplicates entries that appear in multiple cached folders', () { final shared = _file( path: 'Documents/Tax-Report.pdf', name: 'Tax-Report.pdf', ); final dedupRoot = ListFilesResponse({shared}); - final dedupDocs = { - '/MarianumMobile/wd-folder-aaa': _doc(dedupRoot), - '/MarianumMobile/wd-folder-bbb': _doc(dedupRoot), - }; - final hits = await searchLocalCaches('tax', docs: dedupDocs); - expect(hits, hasLength(1)); + final dedupIndex = buildLocalCacheIndex([ + _payload(dedupRoot), + _payload(dedupRoot), + ]); + expect(searchLocalCacheIndex(dedupIndex, 'tax'), hasLength(1)); }); }); } diff --git a/test/view/timetable/lesson_type_label_test.dart b/test/view/timetable/lesson_type_label_test.dart new file mode 100644 index 0000000..86b0f2c --- /dev/null +++ b/test/view/timetable/lesson_type_label_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/lesson_type_label.dart'; + +McTimetableEntry _entry({ + String lessonType = 'LESSON', + String? substitutionText, + String? lessonText, +}) => McTimetableEntry( + id: 1, + date: DateTime(2026, 5, 4), + startTime: DateTime(1970, 1, 1, 8), + endTime: DateTime(1970, 1, 1, 15), + subjects: const [], + teachers: const [], + rooms: const ['A101'], + classNames: const ['8a'], + lessonType: lessonType, + status: 'REGULAR', + substitutionText: substitutionText, + lessonText: lessonText, + infoText: null, +); + +void main() { + group('LessonTypeLabel.forEntry', () { + test('prefers the substitution text', () { + expect( + LessonTypeLabel.forEntry( + _entry(substitutionText: 'Klassenfahrt 8a', lessonText: 'Anderes'), + ), + 'Klassenfahrt 8a', + ); + }); + + test('falls back to the lesson text', () { + expect( + LessonTypeLabel.forEntry(_entry(lessonText: ' Wandertag\n8a ')), + 'Wandertag 8a', + ); + }); + + test('falls back to "Event" with room when no text is set', () { + expect( + LessonTypeLabel.forEntry(_entry(substitutionText: ' ')), + 'A101 Event', + ); + }); + + test('keeps known lesson types over free text', () { + expect( + LessonTypeLabel.forEntry( + _entry(lessonType: 'BREAK_SUPERVISION', lessonText: 'Hof'), + ), + 'A101 Aufsicht', + ); + }); + }); +} diff --git a/test/view/timetable/teacher_class_label_test.dart b/test/view/timetable/teacher_class_label_test.dart index f6cf531..e56d9fc 100644 --- a/test/view/timetable/teacher_class_label_test.dart +++ b/test/view/timetable/teacher_class_label_test.dart @@ -65,10 +65,10 @@ void main() { ); }); - test('falls back to the teacher when the entry has no class', () { + test('shows only the room when the entry has no class', () { expect( _location(showClassInsteadOfTeacher: true, classNames: const []), - 'A101\nMüller', + 'A101', ); }); }); diff --git a/test/widget_data/widget_data_mapper_test.dart b/test/widget_data/widget_data_mapper_test.dart index 91fa69c..c23d286 100644 --- a/test/widget_data/widget_data_mapper_test.dart +++ b/test/widget_data/widget_data_mapper_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import 'package:marianum_mobile/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; @@ -33,6 +34,8 @@ McTimetableEntry _lesson({ String? teacherName, String? teacherOrgname, String? substText, + String lessonType = 'LESSON', + List classNames = const [], }) => McTimetableEntry( id: 1, date: date, @@ -50,8 +53,8 @@ McTimetableEntry _lesson({ ] : const [], rooms: room != null ? [room] : const [], - classNames: const [], - lessonType: 'LESSON', + classNames: classNames, + lessonType: lessonType, status: status, substitutionText: substText, lessonText: null, @@ -117,8 +120,18 @@ void main() { test('only includes lessons on the anchor day', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'MA'), - _lesson(date: DateTime(2026, 5, 7), startHhmm: 800, endHhmm: 845, subjectName: 'EN'), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 800, + endHhmm: 845, + subjectName: 'MA', + ), + _lesson( + date: DateTime(2026, 5, 7), + startHhmm: 800, + endHhmm: 845, + subjectName: 'EN', + ), ]; final data = WidgetDataMapper.buildDayData( now: now, @@ -164,14 +177,11 @@ void main() { rooms: null, holidays: null, ); - expect( - data.lessons.map((l) => l.status).toList(), - [ - WidgetLessonStatus.cancelled, - WidgetLessonStatus.irregular, - WidgetLessonStatus.teacherChanged, - ], - ); + expect(data.lessons.map((l) => l.status).toList(), [ + WidgetLessonStatus.cancelled, + WidgetLessonStatus.irregular, + WidgetLessonStatus.teacherChanged, + ]); }); test('marks day as holiday when in holiday range', () { @@ -224,10 +234,11 @@ void main() { rooms: null, holidays: null, ); - expect( - data.lessons.map((l) => l.subjectShort).toList(), - ['MA', 'EN', 'BIO'], - ); + expect(data.lessons.map((l) => l.subjectShort).toList(), [ + 'MA', + 'EN', + 'BIO', + ]); }); }); @@ -236,9 +247,24 @@ void main() { test('long event bumps every regular lesson it covers', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'MA'), - _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'), - _lesson(date: DateTime(2026, 5, 6), startHhmm: 1000, endHhmm: 1045, subjectName: 'BIO'), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 800, + endHhmm: 845, + subjectName: 'MA', + ), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, + subjectName: 'EN', + ), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 1000, + endHhmm: 1045, + subjectName: 'BIO', + ), ]; final events = GetCustomTimetableEventResponse([ _event( @@ -264,8 +290,18 @@ void main() { test('event + same-slot duplicate regular: kept lesson shows +2', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'), - _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'MA'), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, + subjectName: 'EN', + ), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, + subjectName: 'MA', + ), ]; final events = GetCustomTimetableEventResponse([ _event( @@ -315,7 +351,12 @@ void main() { test('two events covering the same regular lesson bump it twice', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, + subjectName: 'EN', + ), ]; final events = GetCustomTimetableEventResponse([ _event( @@ -361,16 +402,198 @@ void main() { }); }); + group('teacher plan', () { + final now = DateTime(2026, 5, 6, 7); + final day = DateTime(2026, 5, 6); + + WidgetLesson single(McTimetableEntry lesson) => + WidgetDataMapper.buildDayData( + now: now, + lessons: [lesson], + subjects: null, + rooms: null, + holidays: null, + showClassInsteadOfTeacher: true, + ).lessons.single; + + test('shows the classes in place of the teacher', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 755, + endHhmm: 840, + subjectName: 'MA', + teacherName: 'MUE', + teacherOrgname: 'KUN', + classNames: const ['10a', '10b'], + ), + ); + expect(lesson.teacher, '10a, 10b'); + expect(lesson.originalTeacher, isNull); + }); + + test('leaves the slot empty for entries without a class', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 920, + endHhmm: 935, + room: 'Hof', + teacherName: 'MUE', + lessonType: 'BREAK_SUPERVISION', + ), + ); + expect(lesson.teacher, isNull); + }); + }); + + group('tile color', () { + final now = DateTime(2026, 5, 6, 7); + final day = DateTime(2026, 5, 6); + + WidgetLesson single( + McTimetableEntry lesson, { + TimetableGetSubjectsResponse? subjects, + }) => WidgetDataMapper.buildDayData( + now: now, + lessons: [lesson], + subjects: subjects, + rooms: null, + holidays: null, + ).lessons.single; + + test('regular lesson without subject color uses the status red', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 755, + endHhmm: 840, + subjectName: 'MA', + teacherName: 'MUE', + ), + ); + expect(lesson.color, '#993333'); + }); + + test('regular lesson takes the personal subject color', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 755, + endHhmm: 840, + subjectName: 'MA', + teacherName: 'MUE', + ), + subjects: TimetableGetSubjectsResponse( + result: [ + McSubject( + id: 1, + shortName: 'MA', + longName: 'Mathematik', + color: 'indigo', + ), + ], + ), + ); + expect(lesson.color, '#5C6BC0'); + }); + + test('substitution keeps its signal color despite a subject color', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 755, + endHhmm: 840, + status: 'IRREGULAR', + subjectName: 'MA', + teacherName: 'MUE', + ), + subjects: TimetableGetSubjectsResponse( + result: [ + McSubject( + id: 1, + shortName: 'MA', + longName: 'Mathematik', + color: 'indigo', + ), + ], + ), + ); + expect(lesson.status, WidgetLessonStatus.irregular); + expect(lesson.color, '#8F19B3'); + }); + + test('supervision is classified as duty with room in the title', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 920, + endHhmm: 935, + room: 'Hof', + teacherName: 'MUE', + lessonType: 'BREAK_SUPERVISION', + ), + ); + expect(lesson.status, WidgetLessonStatus.duty); + expect(lesson.subjectShort, 'Hof Aufsicht'); + expect(lesson.color, '#00796B'); + }); + + test('custom event resolves legacy color names', () { + final data = WidgetDataMapper.buildDayData( + now: now, + lessons: const [], + subjects: null, + rooms: null, + holidays: null, + customEvents: GetCustomTimetableEventResponse([ + _event( + id: 'e', + title: 'Probe', + start: DateTime(2026, 5, 6, 14), + end: DateTime(2026, 5, 6, 15), + ), + ]), + ); + expect(data.lessons.single.color, '#FFA000'); + }); + }); + group('buildWeekData', () { final now = DateTime(2026, 5, 5, 10); // Tuesday test('covers the 14-day window including next week', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 4), startHhmm: 800, endHhmm: 845, subjectName: 'MO'), - _lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'WE'), - _lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'), - _lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'), - _lesson(date: DateTime(2026, 5, 18), startHhmm: 800, endHhmm: 845, subjectName: 'FAR'), + _lesson( + date: DateTime(2026, 5, 4), + startHhmm: 800, + endHhmm: 845, + subjectName: 'MO', + ), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 800, + endHhmm: 845, + subjectName: 'WE', + ), + _lesson( + date: DateTime(2026, 5, 8), + startHhmm: 800, + endHhmm: 845, + subjectName: 'FR', + ), + _lesson( + date: DateTime(2026, 5, 11), + startHhmm: 800, + endHhmm: 845, + subjectName: 'NEXT', + ), + _lesson( + date: DateTime(2026, 5, 18), + startHhmm: 800, + endHhmm: 845, + subjectName: 'FAR', + ), ]; final data = WidgetDataMapper.buildWeekData( now: now, @@ -380,10 +603,12 @@ void main() { holidays: null, ); expect(data.anchorDate, DateTime(2026, 5, 4)); - expect( - data.lessons.map((l) => l.subjectShort).toList(), - ['MO', 'WE', 'FR', 'NEXT'], - ); + expect(data.lessons.map((l) => l.subjectShort).toList(), [ + 'MO', + 'WE', + 'FR', + 'NEXT', + ]); }); test('window stays on the current week when the anchor rolls forward', () { @@ -391,8 +616,18 @@ void main() { // still starts at the current week's Monday so today stays available. final fridayEvening = DateTime(2026, 5, 8, 18); final lessons = [ - _lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'), - _lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'), + _lesson( + date: DateTime(2026, 5, 8), + startHhmm: 800, + endHhmm: 845, + subjectName: 'FR', + ), + _lesson( + date: DateTime(2026, 5, 11), + startHhmm: 800, + endHhmm: 845, + subjectName: 'NEXT', + ), ]; final data = WidgetDataMapper.buildWeekData( now: fridayEvening, @@ -402,10 +637,7 @@ void main() { holidays: null, ); expect(data.anchorDate, DateTime(2026, 5, 11)); - expect( - data.lessons.map((l) => l.subjectShort).toList(), - ['FR', 'NEXT'], - ); + expect(data.lessons.map((l) => l.subjectShort).toList(), ['FR', 'NEXT']); }); test('carries per-day holiday info in days', () { @@ -429,8 +661,10 @@ void main() { expect(data.days, hasLength(WidgetDataMapper.weekWindowDays)); expect(data.days.first.date, DateTime(2026, 5, 4)); expect(data.days.last.date, DateTime(2026, 5, 17)); - final holidayDays = - data.days.where((d) => d.isHoliday).map((d) => d.date).toList(); + final holidayDays = data.days + .where((d) => d.isHoliday) + .map((d) => d.date) + .toList(); expect(holidayDays, [DateTime(2026, 5, 14), DateTime(2026, 5, 15)]); expect( data.days.firstWhere((d) => d.isHoliday).holidayName,