diff --git a/android/app/build.gradle b/android/app/build.gradle index befe6bb..3131c2e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -65,5 +65,9 @@ flutter { dependencies { implementation 'com.android.support:multidex:2.0.1' + // Same version as workmanager_android pins — needed to enqueue its + // BackgroundWorker from native widget code (the plugin uses + // `implementation`, so androidx.work is not exposed transitively). + implementation 'androidx.work:work-runtime:2.11.2' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' } diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableDayWidget.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableDayWidget.kt index 9b7da6e..b66b18f 100644 --- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableDayWidget.kt +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableDayWidget.kt @@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context import android.os.Bundle +import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer /** @@ -11,6 +12,11 @@ import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer * Flutter plugin resolves the receiver class as `.`. */ class TimetableDayWidget : AppWidgetProvider() { + override fun onEnabled(context: Context) { + // First widget of this kind placed → fetch fresh data right away. + WidgetRefreshRequester.enqueueOneOffRefresh(context) + } + override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableWeekWidget.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableWeekWidget.kt index 9ffcf8f..aee5d23 100644 --- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableWeekWidget.kt +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableWeekWidget.kt @@ -4,9 +4,15 @@ import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context import android.os.Bundle +import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer class TimetableWeekWidget : AppWidgetProvider() { + override fun onEnabled(context: Context) { + // First widget of this kind placed → fetch fresh data right away. + WidgetRefreshRequester.enqueueOneOffRefresh(context) + } + override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRefreshRequester.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRefreshRequester.kt new file mode 100644 index 0000000..ddd2320 --- /dev/null +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRefreshRequester.kt @@ -0,0 +1,40 @@ +package eu.mhsl.marianum.mobile.client.widgets + +import android.content.Context +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import dev.fluttercommunity.workmanager.BackgroundWorker + +/** + * Enqueues the app's Dart one-off widget refresh from native code, so a + * freshly placed widget populates within seconds instead of waiting for the + * next periodic slot. + * + * DART_TASK_KEY is public plugin API but no semver guarantee — re-verify on + * workmanager upgrades. The Dart callback handle comes from SharedPreferences + * persisted by Workmanager().initialize(); if the app never ran, the worker + * fails gracefully and the widget keeps rendering its cached/empty state. + */ +object WidgetRefreshRequester { + // Mirrors WidgetBackgroundTask.oneOffTaskName on the Dart side. + private const val DART_ONE_OFF_TASK = "eu.mhsl.marianum.widget.refresh.once" + + fun enqueueOneOffRefresh(context: Context) { + val request = OneTimeWorkRequest.Builder(BackgroundWorker::class.java) + .setInputData( + Data.Builder() + .putString(BackgroundWorker.DART_TASK_KEY, DART_ONE_OFF_TASK) + .build(), + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .build() + WorkManager.getInstance(context).enqueue(request) + } +} 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 506d4a2..08c5875 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 @@ -736,8 +736,11 @@ object WidgetRenderer { ) } + // Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a + // schema bump must land in Dart, Swift (WidgetData.swift) and here + // together, or the out-of-sync platform silently renders empty. const val KEY_DAY_DATA = "widget_data_day_v1" - const val KEY_WEEK_DATA = "widget_data_week_v1" + const val KEY_WEEK_DATA = "widget_data_week_v2" const val KEY_LOGGED_IN = "widget_data_logged_in_v1" const val KEY_THEME_MODE = "widget_setting_theme_mode_v1" } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 881e60f..be0a2df 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,6 +1,7 @@ import Flutter import UIKit import UserNotifications +import workmanager_apple @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -23,6 +24,19 @@ import UserNotifications ) -> Bool { let result = super.application(application, didFinishLaunchingWithOptions: launchOptions) registerTalkCategory() + // BGAppRefresh for the home-screen widget. Must all happen before + // didFinishLaunching returns: with the UIScene lifecycle Flutter registers + // plugins only during scene connection, which is past BGTaskScheduler's + // registration deadline — registerLaunchHandlers() bridges that gap. + // The task identifier mirrors WidgetBackgroundTask.periodicTaskName; the + // plugin re-submits the refresh request itself after every run. + WorkmanagerPlugin.setPluginRegistrantCallback { registry in + GeneratedPluginRegistrant.register(with: registry) + } + WorkmanagerPlugin.registerPeriodicTask( + withIdentifier: "eu.mhsl.marianum.widget.refresh", + earliestBeginInSeconds: 1800) + WorkmanagerPlugin.registerLaunchHandlers() // FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and // forwards these callbacks to the plugins (firebase_messaging, // flutter_local_notifications). We route Talk actions natively here — the diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index f76a0d3..09dcffc 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -66,6 +66,10 @@ UIApplicationSupportsIndirectInputEvents + BGTaskSchedulerPermittedIdentifiers + + eu.mhsl.marianum.widget.refresh + UIBackgroundModes fetch diff --git a/ios/TimetableWidgetExtension/TimetableAnchor.swift b/ios/TimetableWidgetExtension/TimetableAnchor.swift new file mode 100644 index 0000000..288b3c8 --- /dev/null +++ b/ios/TimetableWidgetExtension/TimetableAnchor.swift @@ -0,0 +1,109 @@ +import Foundation + +/// Pure anchor/slicing logic mirroring `lib/widget_data/widget_data_mapper.dart` +/// (resolveDayAnchor / resolveWeekAnchor). Keep both sides in sync — the Dart +/// unit tests in test/widget_data/widget_data_mapper_test.dart double as the +/// review checklist here: +/// resolveDayAnchor(Wed 2026-05-06 10:00) == 2026-05-06 (before cutoff) +/// resolveDayAnchor(Wed 2026-05-06 19:00) == 2026-05-07 (after cutoff) +/// resolveDayAnchor(Fri 2026-05-08 18:00) == 2026-05-11 (Fri → Mon) +/// resolveDayAnchor(Sat 2026-05-09 10:00) == 2026-05-11 +/// resolveDayAnchor(Sun 2026-05-10 22:00) == 2026-05-11 +/// resolveWeekAnchor(Tue 2026-05-05 10:00) == 2026-05-04 +/// resolveWeekAnchor(Sun 2026-05-10 10:00) == 2026-05-11 +enum TimetableAnchor { + /// After 17:00 the user's question shifts from "what's left today" to + /// "what's tomorrow", so the day widget rolls forward. + static let dayCutoffHour = 17 + + static func resolveDayAnchor(_ now: Date, calendar: Calendar = .current) -> Date { + var candidate = calendar.startOfDay(for: now) + let shiftToTomorrow = + calendar.component(.hour, from: now) >= dayCutoffHour || isWeekend(candidate, calendar: calendar) + if shiftToTomorrow { + candidate = nextDay(candidate, calendar: calendar) + } + while isWeekend(candidate, calendar: calendar) { + candidate = nextDay(candidate, calendar: calendar) + } + return candidate + } + + static func resolveWeekAnchor(_ now: Date, calendar: Calendar = .current) -> Date { + let anchor = resolveDayAnchor(now, calendar: calendar) + // Swift weekday: 1 = Sunday … 7 = Saturday → distance back to Monday. + let daysFromMonday = (calendar.component(.weekday, from: anchor) + 5) % 7 + let monday = calendar.date(byAdding: .day, value: -daysFromMonday, to: anchor) ?? anchor + return calendar.startOfDay(for: monday) + } + + /// Future instants at which the rendered anchor can change: each day's + /// midnight (rollover) and 17:00 (cutoff), within the horizon. Sorted, + /// strictly after `now`. + static func boundaryDates( + from now: Date, + horizonDays: Int = 3, + calendar: Calendar = .current + ) -> [Date] { + var result: [Date] = [] + let today = calendar.startOfDay(for: now) + for offset in 0...horizonDays { + guard let day = calendar.date(byAdding: .day, value: offset, to: today) else { continue } + let midnight = calendar.startOfDay(for: day) + if midnight > now { result.append(midnight) } + if let cutoff = calendar.date( + bySettingHour: dayCutoffHour, minute: 0, second: 0, of: day + ), cutoff > now { + result.append(cutoff) + } + } + return result.sorted() + } + + /// Derives a day payload from the 14-day week payload — same shape the + /// Dart buildDayData produces, since the week payload runs through the + /// identical per-day merge/collision pipeline. + static func slice( + week: WidgetTimetableData, + forDay anchor: Date, + calendar: Calendar = .current + ) -> WidgetTimetableData { + let lessons = week.lessons.filter { calendar.isDate($0.start, inSameDayAs: anchor) } + // Every v2 week payload carries `days` — the key bump guarantees it. + let dayInfo = week.days?.first { calendar.isDate($0.date, inSameDayAs: anchor) } + return WidgetTimetableData( + fetchedAt: week.fetchedAt, + anchorDate: anchor, + lessons: lessons, + periods: week.periods, + isHoliday: dayInfo?.isHoliday ?? false, + holidayName: dayInfo?.holidayName, + days: nil + ) + } + + /// Week payload re-anchored to the week containing/following `anchorDate`. + /// The week view filters its five columns off `anchorDate`, so this alone + /// performs the Friday-evening/weekend jump to next week. + static func retarget(week: WidgetTimetableData, anchorDate: Date) -> WidgetTimetableData { + WidgetTimetableData( + fetchedAt: week.fetchedAt, + anchorDate: anchorDate, + lessons: week.lessons, + periods: week.periods, + isHoliday: week.isHoliday, + holidayName: week.holidayName, + days: week.days + ) + } + + private static func isWeekend(_ date: Date, calendar: Calendar) -> Bool { + let weekday = calendar.component(.weekday, from: date) + return weekday == 1 || weekday == 7 + } + + private static func nextDay(_ date: Date, calendar: Calendar) -> Date { + let next = calendar.date(byAdding: .day, value: 1, to: date) ?? date + return calendar.startOfDay(for: next) + } +} diff --git a/ios/TimetableWidgetExtension/TimetableDayView.swift b/ios/TimetableWidgetExtension/TimetableDayView.swift index 5678b0d..7ab20e3 100644 --- a/ios/TimetableWidgetExtension/TimetableDayView.swift +++ b/ios/TimetableWidgetExtension/TimetableDayView.swift @@ -100,13 +100,13 @@ struct TimetableDayView: View { private func header(data: WidgetTimetableData) -> some View { HStack(spacing: 4) { - Text(dayLabel(for: data.anchorDate)) + Text(dayLabel(for: data.anchorDate, relativeTo: entry.date)) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(palette.textPrimary) .lineLimit(1) .minimumScaleFactor(0.7) Spacer(minLength: 4) - Text("Stand: \(freshnessLabel(for: data.fetchedAt))") + Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") .font(.system(size: 9)) .foregroundStyle(palette.textSecondary) .lineLimit(1) @@ -383,6 +383,7 @@ struct TimeGridView: View { 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) } } } @@ -400,9 +401,31 @@ func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] { return result.sorted() } -func dayLabel(for date: Date) -> String { +/// Fixed-locale formatters cached once — DateFormatter setup is the +/// expensive part and the multi-entry timelines render up to ~8 entries per +/// reload. Only touched from WidgetKit's archival rendering, so the shared +/// instances are safe. +enum WidgetDateFormatters { + static let shortDate = make("dd.MM.") + static let weekdayShort = make("EE") + static let weekdayDate = make("EEEE · dd.MM.") + static let time = make("HH:mm") + static let dateTime = make("dd.MM. HH:mm") + + private static func make(_ format: String) -> DateFormatter { + let f = DateFormatter() + f.locale = Locale(identifier: "de_DE") + f.dateFormat = format + return f + } +} + +/// `now` is the timeline entry's date, not `Date()`: WidgetKit archives +/// entries ahead of time, so wall-clock reads would be wrong for every +/// entry after the first. +func dayLabel(for date: Date, relativeTo now: Date) -> String { let cal = Calendar.current - let today = cal.startOfDay(for: Date()) + let today = cal.startOfDay(for: now) let anchor = cal.startOfDay(for: date) if anchor == today { return "Heute · \(shortDate(date))" @@ -410,35 +433,23 @@ func dayLabel(for date: Date) -> String { if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow { return "Morgen · \(shortDate(date))" } - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "de_DE") - formatter.dateFormat = "EEEE · dd.MM." - return formatter.string(from: date) + return WidgetDateFormatters.weekdayDate.string(from: date) } func shortDate(_ date: Date) -> String { - let f = DateFormatter() - f.locale = Locale(identifier: "de_DE") - f.dateFormat = "dd.MM." - return f.string(from: date) + WidgetDateFormatters.shortDate.string(from: date) } -func freshnessLabel(for fetchedAt: Date) -> String { +func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String { let cal = Calendar.current - let today = cal.startOfDay(for: Date()) + let today = cal.startOfDay(for: now) let fetchedDay = cal.startOfDay(for: fetchedAt) - let timeFmt = DateFormatter() - timeFmt.locale = Locale(identifier: "de_DE") - timeFmt.dateFormat = "HH:mm" if fetchedDay == today { - return timeFmt.string(from: fetchedAt) + return WidgetDateFormatters.time.string(from: fetchedAt) } if let yesterday = cal.date(byAdding: .day, value: -1, to: today), fetchedDay == yesterday { - return "gestern \(timeFmt.string(from: fetchedAt))" + return "gestern \(WidgetDateFormatters.time.string(from: fetchedAt))" } - let dateTimeFmt = DateFormatter() - dateTimeFmt.locale = Locale(identifier: "de_DE") - dateTimeFmt.dateFormat = "dd.MM. HH:mm" - return dateTimeFmt.string(from: fetchedAt) + return WidgetDateFormatters.dateTime.string(from: fetchedAt) } diff --git a/ios/TimetableWidgetExtension/TimetableWeekView.swift b/ios/TimetableWidgetExtension/TimetableWeekView.swift index 3e3eb22..123b0d0 100644 --- a/ios/TimetableWidgetExtension/TimetableWeekView.swift +++ b/ios/TimetableWidgetExtension/TimetableWeekView.swift @@ -70,7 +70,7 @@ struct TimetableWeekView: View { .lineLimit(1) .minimumScaleFactor(0.8) Spacer() - Text("Stand: \(freshnessLabel(for: data.fetchedAt))") + Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") .font(.system(size: 9)) .foregroundStyle(palette.textSecondary) .lineLimit(1) @@ -168,10 +168,7 @@ struct TimetableWeekView: View { } private func weekday(for date: Date) -> String { - let f = DateFormatter() - f.locale = Locale(identifier: "de_DE") - f.dateFormat = "EE" - return f.string(from: date) + WidgetDateFormatters.weekdayShort.string(from: date) } private func placeholder(_ message: String) -> some View { diff --git a/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift b/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift index e231ea1..97c425f 100644 --- a/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift +++ b/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift @@ -41,11 +41,13 @@ struct TimetableDayProvider: TimelineProvider { in context: Context, completion: @escaping (Timeline) -> Void ) { - let entry = TimetableEntry.current(variant: .day) + let now = Date() // 30 min mirrors the Dart workmanager cadence. iOS treats this as - // advisory; the "Stand:" label tells the user when data is stale. - let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date() - completion(Timeline(entries: [entry], policy: .after(next))) + // advisory; the boundary entries below keep the rendered day correct + // even when no reload is granted, and the "Stand:" label tells the + // user when the underlying data is stale. + let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now + completion(Timeline(entries: TimetableEntry.dayEntries(now: now), policy: .after(next))) } } @@ -80,9 +82,9 @@ struct TimetableWeekProvider: TimelineProvider { in context: Context, completion: @escaping (Timeline) -> Void ) { - let entry = TimetableEntry.current(variant: .week) - let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date() - completion(Timeline(entries: [entry], policy: .after(next))) + let now = Date() + let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now + completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next))) } } @@ -120,6 +122,58 @@ struct TimetableEntry: TimelineEntry { themeMode: WidgetDataLoader.themeMode() ) } + + /// Day timeline derived from the 14-day week payload, so the widget shows + /// the right day even when iOS grants no reload for days. + static func dayEntries(now: Date) -> [TimetableEntry] { + entries(now: now, variant: .day) { week, date in + TimetableAnchor.slice(week: week, forDay: TimetableAnchor.resolveDayAnchor(date)) + } + } + + /// Week timeline: re-anchoring performs the Friday-evening/weekend jump + /// into next week from cached data, and the midnight entries keep the + /// "Stand:" freshness label honest. + static func weekEntries(now: Date) -> [TimetableEntry] { + entries(now: now, variant: .week) { week, date in + TimetableAnchor.retarget(week: week, anchorDate: TimetableAnchor.resolveWeekAnchor(date)) + } + } + + /// Shared timeline skeleton: one entry now plus one per anchor boundary + /// (midnight rollover, 17:00 cutoff). Boundaries that cannot change the + /// render — same anchor and same calendar day for the header labels — + /// are dropped. + private static func entries( + now: Date, + variant: TimetableVariant, + transform: (WidgetTimetableData, Date) -> WidgetTimetableData + ) -> [TimetableEntry] { + guard WidgetDataLoader.isLoggedIn(), let week = WidgetDataLoader.loadWeek() else { + // Logged out, or no v2 week snapshot yet (fresh app update): + // fall back to the legacy single-entry payload. + return [TimetableEntry.current(variant: variant)] + } + let theme = WidgetDataLoader.themeMode() + let cal = Calendar.current + var result: [TimetableEntry] = [] + for date in [now] + TimetableAnchor.boundaryDates(from: now) { + let data = transform(week, date) + if let previous = result.last, let previousData = previous.data, + cal.isDate(previousData.anchorDate, inSameDayAs: data.anchorDate), + cal.isDate(previous.date, inSameDayAs: date) { + continue + } + result.append(TimetableEntry( + date: date, + variant: variant, + data: data, + isLoggedIn: true, + themeMode: theme + )) + } + return result + } } extension View { diff --git a/ios/TimetableWidgetExtension/WidgetData.swift b/ios/TimetableWidgetExtension/WidgetData.swift index 49bd54c..ae0f0a3 100644 --- a/ios/TimetableWidgetExtension/WidgetData.swift +++ b/ios/TimetableWidgetExtension/WidgetData.swift @@ -10,6 +10,15 @@ enum WidgetLessonStatus: String, Codable { case irregular case teacherChanged case event + case duty + + /// Unknown future statuses degrade to `.regular` instead of failing the + /// whole payload decode (mirrors WidgetData.kt's fromWire fallback) — a + /// single new enum value must never blank the widget to the placeholder. + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = WidgetLessonStatus(rawValue: raw) ?? .regular + } } struct WidgetLesson: Codable { @@ -33,6 +42,12 @@ struct WidgetPeriod: Codable { let virtualEndMinutes: Int } +struct WidgetDayInfo: Codable { + let date: Date + let isHoliday: Bool + let holidayName: String? +} + struct WidgetTimetableData: Codable { let fetchedAt: Date let anchorDate: Date @@ -40,12 +55,17 @@ struct WidgetTimetableData: Codable { let periods: [WidgetPeriod] let isHoliday: Bool let holidayName: String? + /// Week payload (v2) only; optional so day payloads keep decoding. + let days: [WidgetDayInfo]? } +/// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a +/// schema bump must land in Dart, Kotlin (WidgetRenderer.kt) and here +/// together, or the out-of-sync platform silently blanks to the placeholder. enum WidgetDataKey { static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget" static let dayData = "widget_data_day_v1" - static let weekData = "widget_data_week_v1" + static let weekData = "widget_data_week_v2" static let loggedIn = "widget_data_logged_in_v1" static let themeMode = "widget_setting_theme_mode_v1" } diff --git a/lib/background/widget_background_task.dart b/lib/background/widget_background_task.dart index 02b8bfd..30560a8 100644 --- a/lib/background/widget_background_task.dart +++ b/lib/background/widget_background_task.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:developer'; +import 'dart:io'; import 'package:flutter/widgets.dart'; import 'package:workmanager/workmanager.dart'; @@ -32,12 +33,20 @@ class WidgetBackgroundTask { static const Duration periodicFrequency = Duration(minutes: 30); + /// A snapshot younger than this is considered fresh enough — a second + /// trigger within the window (push + periodic slot coinciding) is skipped. + static const Duration refreshDebounce = Duration(minutes: 10); + static Future initialize() async { await Workmanager().initialize(_callbackDispatcher); await Workmanager().registerPeriodicTask( periodicTaskName, periodicTaskName, frequency: periodicFrequency, + // iOS ignores `frequency:` and instead uses initialDelay as the + // BGAppRefresh earliestBeginDate on every (auto-)resubmission — + // without it each completed run is immediately eligible again. + initialDelay: Platform.isIOS ? periodicFrequency : Duration.zero, constraints: Constraints(networkType: NetworkType.connected), existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, backoffPolicy: BackoffPolicy.linear, @@ -45,7 +54,21 @@ class WidgetBackgroundTask { ); } - static Future requestImmediateRefresh() async { + /// Single owner of the platform strategy for "refresh soon": Android + /// enqueues a WorkManager one-off (retry + network constraint included), + /// iOS runs inline — one-off Workmanager tasks there only execute + /// in-process anyway, so the direct call is equivalent and skips the extra + /// background engine. [inlineTimeout] bounds the inline path for callers + /// with a hard budget (FCM handler). + static Future requestImmediateRefresh({ + bool force = true, + Duration? inlineTimeout, + }) async { + if (Platform.isIOS) { + final refresh = runRefreshNow(force: force); + await (inlineTimeout == null ? refresh : refresh.timeout(inlineTimeout)); + return; + } await Workmanager().registerOneOffTask( '$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}', oneOffTaskName, @@ -54,24 +77,61 @@ class WidgetBackgroundTask { ); } + /// Shared refresh entry for the periodic worker, push triggers, and login. + /// Throws on fetch failure so the worker path can signal a retry. + static Future runRefreshNow({bool force = false}) async { + await WidgetSync.ensureInitialized(); + bool populated; + try { + // Bounded: a hanging keystore read must not stall the caller's budget + // (FCM handler ~25s on iOS) forever. + populated = await AccountData().waitForPopulation().timeout( + const Duration(seconds: 10), + ); + } on TimeoutException { + populated = false; + } + if (!populated) { + // Deliberately does NOT flip the widget to logged-out: a failed or slow + // keychain read (locked iOS device during the 06:00 silent push) is + // indistinguishable from "never logged in" here, and blanking the + // widget on a transient failure is worse than keeping the snapshot. + // Logout/login manage the flag explicitly (WidgetSync.clear / login). + log('[widget-refresh] credentials unavailable, skipping refresh'); + return; + } + final fetchedAt = await WidgetSync.getFetchedAt(); + if (shouldSkipRefresh(fetchedAt: fetchedAt, now: DateTime.now(), force: force)) { + log('[widget-refresh] snapshot is fresh, skipping refresh'); + return; + } + await _refresh(); + } + static Future cancelAll() async { await Workmanager().cancelAll(); } } +/// Pure debounce decision so it stays unit-testable. A `fetchedAt` in the +/// future (clock change, debug time shift) never skips — refreshing is the +/// safe direction. +bool shouldSkipRefresh({ + required DateTime? fetchedAt, + required DateTime now, + required bool force, +}) { + if (force || fetchedAt == null) return false; + final age = now.difference(fetchedAt); + return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce; +} + @pragma('vm:entry-point') void _callbackDispatcher() { Workmanager().executeTask((task, inputData) async { try { WidgetsFlutterBinding.ensureInitialized(); - await AccountData().waitForPopulation(); - if (!AccountData().isPopulated()) { - log('[widget-bg] not logged in, skipping refresh'); - await WidgetSync.setLoggedIn(false); - await WidgetSync.triggerUpdate(); - return true; - } - await _refresh(); + await WidgetBackgroundTask.runRefreshNow(); return true; } on Exception catch (e, s) { log('[widget-bg] refresh failed: $e', stackTrace: s); @@ -94,35 +154,44 @@ Future _refresh() async { } final now = WidgetPublisher.widgetNow(); - // 14-day window so the week-widget rolls forward into next Monday's - // lessons on Friday evening. - final weekStart = _startOfWeek(now); - final weekEndExclusive = weekStart.add(const Duration(days: 14)); + // Fetch window matches the week payload's window so the widget can roll + // forward into next week's lessons without fresh data. + final weekStart = WidgetDataMapper.startOfCalendarWeek(now); + final weekEndExclusive = weekStart.add( + const Duration(days: WidgetDataMapper.weekWindowDays), + ); - final timetable = await TimetableGetWeek().run( + // All six requests are independent — run them concurrently so the total + // latency is the slowest request, not the sum (matters for the push path's + // hard time budget). Reference-data failures fall through to null in the + // mapper rather than aborting the whole refresh. + final timetableFuture = TimetableGetWeek().run( from: weekStart, until: weekEndExclusive.subtract(const Duration(days: 1)), ); - - // Reference data — failures fall through to null in the mapper rather - // than aborting the whole refresh. - final subjects = await _runOrNull( + final subjectsFuture = _runOrNull( () => TimetableGetSubjects().run(), ); - final rooms = await _runOrNull( + final roomsFuture = _runOrNull( () => TimetableGetRooms().run(), ); - final holidays = await _runOrNull( + final holidaysFuture = _runOrNull( () => TimetableGetHolidays().run(), ); - final timegrid = await _runOrNull( + final timegridFuture = _runOrNull( () => TimetableGetTimegrid().run(), ); - final customEvents = await _runOrNull( + final customEventsFuture = _runOrNull( () => GetCustomTimetableEvent( GetCustomTimetableEventParams(AccountData().getUserSecret()), ).run(), ); + final timetable = await timetableFuture; + final subjects = await subjectsFuture; + final rooms = await roomsFuture; + final holidays = await holidaysFuture; + final timegrid = await timegridFuture; + final customEvents = await customEventsFuture; final lessons = timetable.entries; @@ -158,11 +227,6 @@ Future _refresh() async { ); } -DateTime _startOfWeek(DateTime reference) { - final monday = reference.subtract(Duration(days: reference.weekday - 1)); - return DateTime(monday.year, monday.month, monday.day); -} - Future _runOrNull(Future Function() task) async { try { return await task(); diff --git a/lib/push/push_message_handler.dart b/lib/push/push_message_handler.dart index b72145c..1d64116 100644 --- a/lib/push/push_message_handler.dart +++ b/lib/push/push_message_handler.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:crypton/crypton.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; +import '../background/widget_background_task.dart'; import '../notification/notification_service.dart'; import 'chat_thread_store.dart'; import 'nid_store.dart'; @@ -12,6 +13,10 @@ import 'push_registration_store.dart'; import 'push_renderer.dart'; import 'push_subject.dart'; +/// Wire value of the FCM `type` field for silent widget-refresh pushes. +/// Mirrors PUSH_TYPE_WIDGET_REFRESH in MarianumConnect's MarMobileApiService. +const String widgetRefreshPushType = 'widget-refresh'; + /// How an incoming FCM payload should be interpreted. enum PushKind { /// Encrypted Nextcloud push-v2 notification (`subject` + `signature`). @@ -20,6 +25,11 @@ enum PushKind { /// Plaintext MarianumConnect direct push (`source == "connect"`). connect, + /// Silent MarianumConnect push requesting a home-widget data refresh + /// (`source == "connect"` + `type == "widget-refresh"`). Never rendered, + /// processed even with notifications off. + widgetRefresh, + /// Neither — ignored. unknown, } @@ -31,7 +41,10 @@ PushKind classifyPush(Map data) { final hasSubject = (data['subject'] as String?)?.isNotEmpty ?? false; final hasSignature = (data['signature'] as String?)?.isNotEmpty ?? false; if (hasSubject && hasSignature) return PushKind.nextcloud; - if (data['source'] == 'connect') return PushKind.connect; + if (data['source'] == 'connect') { + if (data['type'] == widgetRefreshPushType) return PushKind.widgetRefresh; + return PushKind.connect; + } return PushKind.unknown; } @@ -97,11 +110,30 @@ class PushMessageHandler { notificationsEnabled: notificationsEnabled, ); break; + case PushKind.widgetRefresh: + // Deliberately before any notificationsEnabled gate: silent sync + // pushes must work with notifications off. + await _handleWidgetRefresh(); + break; case PushKind.unknown: break; } } + Future _handleWidgetRefresh() async { + try { + // The iOS FCM handler runs in the main isolate with a ~25s APNs + // budget — bound the inline refresh below that so the completion + // handler always fires in time. + await WidgetBackgroundTask.requestImmediateRefresh( + force: false, + inlineTimeout: const Duration(seconds: 20), + ); + } on Exception catch (e) { + log('[push] widget refresh failed: $e'); + } + } + Future _handleConnect( RemoteMessage message, { required bool foreground, diff --git a/lib/widget_data/widget_data.dart b/lib/widget_data/widget_data.dart index 6849d16..1549eb3 100644 --- a/lib/widget_data/widget_data.dart +++ b/lib/widget_data/widget_data.dart @@ -61,6 +61,20 @@ abstract class WidgetPeriod with _$WidgetPeriod { _$WidgetPeriodFromJson(json); } +/// Per-day metadata for the week payload, so native renderers can derive a +/// single day's view (including its holiday state) without a day payload. +@freezed +abstract class WidgetDayInfo with _$WidgetDayInfo { + const factory WidgetDayInfo({ + required DateTime date, + @Default(false) bool isHoliday, + String? holidayName, + }) = _WidgetDayInfo; + + factory WidgetDayInfo.fromJson(Map json) => + _$WidgetDayInfoFromJson(json); +} + @freezed abstract class WidgetTimetableData with _$WidgetTimetableData { const factory WidgetTimetableData({ @@ -73,6 +87,8 @@ abstract class WidgetTimetableData with _$WidgetTimetableData { @Default([]) List periods, @Default(false) bool isHoliday, String? holidayName, + /// Week payload only: one entry per day of the covered window. + @Default([]) List days, }) = _WidgetTimetableData; factory WidgetTimetableData.fromJson(Map json) => diff --git a/lib/widget_data/widget_data.freezed.dart b/lib/widget_data/widget_data.freezed.dart index b0faca4..4d038fa 100644 --- a/lib/widget_data/widget_data.freezed.dart +++ b/lib/widget_data/widget_data.freezed.dart @@ -593,13 +593,283 @@ as int, } +/// @nodoc +mixin _$WidgetDayInfo { + + DateTime get date; bool get isHoliday; String? get holidayName; +/// Create a copy of WidgetDayInfo +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$WidgetDayInfoCopyWith get copyWith => _$WidgetDayInfoCopyWithImpl(this as WidgetDayInfo, _$identity); + + /// Serializes this WidgetDayInfo to a JSON map. + 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)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,date,isHoliday,holidayName); + +@override +String toString() { + return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)'; +} + + +} + +/// @nodoc +abstract mixin class $WidgetDayInfoCopyWith<$Res> { + factory $WidgetDayInfoCopyWith(WidgetDayInfo value, $Res Function(WidgetDayInfo) _then) = _$WidgetDayInfoCopyWithImpl; +@useResult +$Res call({ + DateTime date, bool isHoliday, String? holidayName +}); + + + + +} +/// @nodoc +class _$WidgetDayInfoCopyWithImpl<$Res> + implements $WidgetDayInfoCopyWith<$Res> { + _$WidgetDayInfoCopyWithImpl(this._self, this._then); + + final WidgetDayInfo _self; + final $Res Function(WidgetDayInfo) _then; + +/// 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( +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 +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [WidgetDayInfo]. +extension WidgetDayInfoPatterns on WidgetDayInfo { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _WidgetDayInfo value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _WidgetDayInfo() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _WidgetDayInfo value) $default,){ +final _that = this; +switch (_that) { +case _WidgetDayInfo(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _WidgetDayInfo value)? $default,){ +final _that = this; +switch (_that) { +case _WidgetDayInfo() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime date, bool isHoliday, String? holidayName)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _WidgetDayInfo() when $default != null: +return $default(_that.date,_that.isHoliday,_that.holidayName);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( DateTime date, bool isHoliday, String? holidayName) $default,) {final _that = this; +switch (_that) { +case _WidgetDayInfo(): +return $default(_that.date,_that.isHoliday,_that.holidayName);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime date, bool isHoliday, String? holidayName)? $default,) {final _that = this; +switch (_that) { +case _WidgetDayInfo() when $default != null: +return $default(_that.date,_that.isHoliday,_that.holidayName);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _WidgetDayInfo implements WidgetDayInfo { + const _WidgetDayInfo({required this.date, this.isHoliday = false, this.holidayName}); + factory _WidgetDayInfo.fromJson(Map json) => _$WidgetDayInfoFromJson(json); + +@override final DateTime date; +@override@JsonKey() final bool isHoliday; +@override final String? holidayName; + +/// Create a copy of WidgetDayInfo +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$WidgetDayInfoCopyWith<_WidgetDayInfo> get copyWith => __$WidgetDayInfoCopyWithImpl<_WidgetDayInfo>(this, _$identity); + +@override +Map toJson() { + return _$WidgetDayInfoToJson(this, ); +} + +@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)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,date,isHoliday,holidayName); + +@override +String toString() { + return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)'; +} + + +} + +/// @nodoc +abstract mixin class _$WidgetDayInfoCopyWith<$Res> implements $WidgetDayInfoCopyWith<$Res> { + factory _$WidgetDayInfoCopyWith(_WidgetDayInfo value, $Res Function(_WidgetDayInfo) _then) = __$WidgetDayInfoCopyWithImpl; +@override @useResult +$Res call({ + DateTime date, bool isHoliday, String? holidayName +}); + + + + +} +/// @nodoc +class __$WidgetDayInfoCopyWithImpl<$Res> + implements _$WidgetDayInfoCopyWith<$Res> { + __$WidgetDayInfoCopyWithImpl(this._self, this._then); + + final _WidgetDayInfo _self; + final $Res Function(_WidgetDayInfo) _then; + +/// Create a copy of WidgetDayInfo +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? date = null,Object? isHoliday = null,Object? holidayName = freezed,}) { + 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 +as String?, + )); +} + + +} + + /// @nodoc mixin _$WidgetTimetableData { DateTime get fetchedAt;/// The day this widget snapshot is "about" — display anchor. /// For the day variant: the rendered school day. /// For the week variant: the Monday of the rendered school week. - DateTime get anchorDate; List get lessons; List get periods; bool get isHoliday; String? get holidayName; + DateTime get anchorDate; List get lessons; List get periods; bool get isHoliday; String? get holidayName;/// Week payload only: one entry per day of the covered window. + List get days; /// Create a copy of WidgetTimetableData /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -612,16 +882,16 @@ $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)); + 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); +int get hashCode => 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)'; + return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)'; } @@ -632,7 +902,7 @@ abstract mixin class $WidgetTimetableDataCopyWith<$Res> { factory $WidgetTimetableDataCopyWith(WidgetTimetableData value, $Res Function(WidgetTimetableData) _then) = _$WidgetTimetableDataCopyWithImpl; @useResult $Res call({ - DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName + DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName, List days }); @@ -649,7 +919,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,}) { +@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( 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 @@ -657,7 +927,8 @@ as DateTime,lessons: null == lessons ? _self.lessons : lessons // ignore: cast_n as List,periods: null == periods ? _self.periods : periods // ignore: cast_nullable_to_non_nullable as List,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 -as String?, +as String?,days: null == days ? _self.days : days // ignore: cast_nullable_to_non_nullable +as List, )); } @@ -742,10 +1013,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName, List days)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _WidgetTimetableData() when $default != null: -return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _: +return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName,_that.days);case _: return orElse(); } @@ -763,10 +1034,10 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName, List days) $default,) {final _that = this; switch (_that) { case _WidgetTimetableData(): -return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _: +return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName,_that.days);case _: throw StateError('Unexpected subclass'); } @@ -783,10 +1054,10 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName, List days)? $default,) {final _that = this; switch (_that) { case _WidgetTimetableData() when $default != null: -return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _: +return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName,_that.days);case _: return null; } @@ -798,7 +1069,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}): _lessons = lessons,_periods = periods; + 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; factory _WidgetTimetableData.fromJson(Map json) => _$WidgetTimetableDataFromJson(json); @override final DateTime fetchedAt; @@ -822,6 +1093,15 @@ class _WidgetTimetableData implements WidgetTimetableData { @override@JsonKey() final bool isHoliday; @override final String? holidayName; +/// Week payload only: one entry per day of the covered window. + final List _days; +/// Week payload only: one entry per day of the covered window. +@override@JsonKey() List get days { + if (_days is EqualUnmodifiableListView) return _days; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_days); +} + /// Create a copy of WidgetTimetableData /// with the given fields replaced by the non-null parameter values. @@ -836,16 +1116,16 @@ 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)); + 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); +int get hashCode => 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)'; + return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)'; } @@ -856,7 +1136,7 @@ abstract mixin class _$WidgetTimetableDataCopyWith<$Res> implements $WidgetTimet factory _$WidgetTimetableDataCopyWith(_WidgetTimetableData value, $Res Function(_WidgetTimetableData) _then) = __$WidgetTimetableDataCopyWithImpl; @override @useResult $Res call({ - DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName + DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName, List days }); @@ -873,7 +1153,7 @@ class __$WidgetTimetableDataCopyWithImpl<$Res> /// Create a copy of WidgetTimetableData /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,}) { +@override @pragma('vm:prefer-inline') $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(_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 @@ -881,7 +1161,8 @@ as DateTime,lessons: null == lessons ? _self._lessons : lessons // ignore: cast_ as List,periods: null == periods ? _self._periods : periods // ignore: cast_nullable_to_non_nullable as List,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 -as String?, +as String?,days: null == days ? _self._days : days // ignore: cast_nullable_to_non_nullable +as List, )); } diff --git a/lib/widget_data/widget_data.g.dart b/lib/widget_data/widget_data.g.dart index f0e3e67..6a836c3 100644 --- a/lib/widget_data/widget_data.g.dart +++ b/lib/widget_data/widget_data.g.dart @@ -63,6 +63,20 @@ Map _$WidgetPeriodToJson(_WidgetPeriod instance) => 'virtualEndMinutes': instance.virtualEndMinutes, }; +_WidgetDayInfo _$WidgetDayInfoFromJson(Map json) => + _WidgetDayInfo( + date: DateTime.parse(json['date'] as String), + isHoliday: json['isHoliday'] as bool? ?? false, + holidayName: json['holidayName'] as String?, + ); + +Map _$WidgetDayInfoToJson(_WidgetDayInfo instance) => + { + 'date': instance.date.toIso8601String(), + 'isHoliday': instance.isHoliday, + 'holidayName': instance.holidayName, + }; + _WidgetTimetableData _$WidgetTimetableDataFromJson(Map json) => _WidgetTimetableData( fetchedAt: DateTime.parse(json['fetchedAt'] as String), @@ -77,6 +91,11 @@ _WidgetTimetableData _$WidgetTimetableDataFromJson(Map json) => const [], isHoliday: json['isHoliday'] as bool? ?? false, holidayName: json['holidayName'] as String?, + days: + (json['days'] as List?) + ?.map((e) => WidgetDayInfo.fromJson(e as Map)) + .toList() ?? + const [], ); Map _$WidgetTimetableDataToJson( @@ -88,4 +107,5 @@ Map _$WidgetTimetableDataToJson( 'periods': instance.periods, 'isHoliday': instance.isHoliday, 'holidayName': instance.holidayName, + 'days': instance.days, }; diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart index b1f070c..45cda8f 100644 --- a/lib/widget_data/widget_data_mapper.dart +++ b/lib/widget_data/widget_data_mapper.dart @@ -9,6 +9,7 @@ import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_time import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; 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 '../view/pages/timetable/data/lesson_merger.dart'; import '../view/pages/timetable/data/lesson_period_schedule.dart'; import '../view/pages/timetable/data/lesson_status.dart'; @@ -34,12 +35,20 @@ class WidgetDataMapper { return candidate; } - static DateTime resolveWeekAnchor(DateTime now) { - final anchor = resolveDayAnchor(now); - final monday = anchor.subtract(Duration(days: anchor.weekday - 1)); + static DateTime resolveWeekAnchor(DateTime now) => + startOfCalendarWeek(resolveDayAnchor(now)); + + /// Monday of the calendar week containing [reference] — no roll-forward, + /// unlike [resolveWeekAnchor]. Start of the week payload's 14-day window. + static DateTime startOfCalendarWeek(DateTime reference) { + final monday = reference.subtract(Duration(days: reference.weekday - 1)); return DateTime(monday.year, monday.month, monday.day); } + /// Days covered by the week payload: current calendar week + the next, so + /// native renderers can roll the view forward without fresh data. + static const int weekWindowDays = 14; + static WidgetTimetableData buildDayData({ required DateTime now, required Iterable lessons, @@ -83,10 +92,14 @@ class WidgetDataMapper { bool connectDoubleLessons = true, }) { final anchor = resolveWeekAnchor(now); - final endExclusive = anchor.add(const Duration(days: 5)); + // The window is anchored at the *current* calendar week, not the + // (possibly rolled-forward) week anchor: on Friday evening the payload + // must still contain today for renderers that derive day slices. + final windowStart = startOfCalendarWeek(now); + final endExclusive = windowStart.add(const Duration(days: weekWindowDays)); final weekLessons = lessons.where((l) { final dt = l.startDateTime; - return !dt.isBefore(anchor) && dt.isBefore(endExclusive); + return !dt.isBefore(windowStart) && dt.isBefore(endExclusive); }).toList(); // Per-day merge: otherwise a 4th-period lesson on Mon would collapse with // a 1st-period lesson on Tue if subject/teacher match. @@ -95,13 +108,38 @@ class WidgetDataMapper { : weekLessons; final mapped = [ ...source.map((l) => _mapLesson(l, now, subjects, rooms)), - ..._expandCustomEvents(customEvents, anchor, endExclusive), + ..._expandCustomEvents(customEvents, windowStart, endExclusive), ]..sort((a, b) => a.start.compareTo(b.start)); + final days = [ + for (var i = 0; i < weekWindowDays; i++) + _dayInfo(windowStart.addDays(i), holidays), + ]; + // The anchor always lies inside the window; the orElse only guards the + // impossible. + final anchorInfo = days.firstWhere( + (d) => d.date == anchor, + orElse: () => _dayInfo(anchor, holidays), + ); return WidgetTimetableData( fetchedAt: now, anchorDate: anchor, lessons: _resolveCollisions(mapped), periods: _resolvePeriods(timegrid), + isHoliday: anchorInfo.isHoliday, + holidayName: anchorInfo.holidayName, + days: days, + ); + } + + static WidgetDayInfo _dayInfo( + DateTime day, + TimetableGetHolidaysResponse? holidays, + ) { + final holiday = _findHoliday(day, holidays); + return WidgetDayInfo( + date: day, + isHoliday: holiday != null, + holidayName: holiday?.longName, ); } diff --git a/lib/widget_data/widget_sync.dart b/lib/widget_data/widget_sync.dart index d94cbd4..8ad02a0 100644 --- a/lib/widget_data/widget_sync.dart +++ b/lib/widget_data/widget_sync.dart @@ -12,14 +12,18 @@ class WidgetSync { static const String iosAppGroupId = 'group.eu.mhsl.marianum.mobile.client.widget'; - static const String iosWidgetKind = 'TimetableWidget'; + // Must match the WidgetKit `kind` strings declared in + // TimetableWidgetExtension.swift — a mismatch makes reloadTimelines a no-op. + static const String iosDayWidgetKind = 'TimetableDayWidget'; + static const String iosWeekWidgetKind = 'TimetableWeekWidget'; static const String androidDayProvider = 'TimetableDayWidget'; static const String androidWeekProvider = 'TimetableWeekWidget'; - // `_v1` suffix lets a future schema change invalidate stale snapshots - // by bumping the key instead of risking a parse crash. + // Version suffix lets a schema change invalidate stale snapshots by + // bumping the key instead of risking a parse crash. static const String dayDataKey = 'widget_data_day_v1'; - static const String weekDataKey = 'widget_data_week_v1'; + // v2: 14-day window + per-day `days` holiday info. + static const String weekDataKey = 'widget_data_week_v2'; static const String fetchedAtKey = 'widget_data_fetched_at_v1'; static const String loggedInKey = 'widget_data_logged_in_v1'; // Mirrored into widget storage so the background isolate can read it @@ -90,6 +94,12 @@ class WidgetSync { return HomeWidget.getWidgetData(marianumConnectBaseUrlKey); } + static Future getFetchedAt() async { + await ensureInitialized(); + final raw = await HomeWidget.getWidgetData(fetchedAtKey); + return raw == null ? null : DateTime.tryParse(raw); + } + static Future clear() async { await ensureInitialized(); await HomeWidget.saveWidgetData(dayDataKey, null); @@ -103,11 +113,11 @@ class WidgetSync { try { await HomeWidget.updateWidget( androidName: androidDayProvider, - iOSName: iosWidgetKind, + iOSName: iosDayWidgetKind, ); await HomeWidget.updateWidget( androidName: androidWeekProvider, - iOSName: iosWidgetKind, + iOSName: iosWeekWidgetKind, ); } on Exception catch (e) { log('WidgetSync.triggerUpdate failed: $e'); diff --git a/test/background/widget_background_task_test.dart b/test/background/widget_background_task_test.dart new file mode 100644 index 0000000..b15eaca --- /dev/null +++ b/test/background/widget_background_task_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/background/widget_background_task.dart'; + +void main() { + final now = DateTime(2026, 8, 6, 12, 0); + + group('shouldSkipRefresh', () { + test('skips when the snapshot is younger than the debounce window', () { + expect( + shouldSkipRefresh( + fetchedAt: now.subtract(const Duration(minutes: 5)), + now: now, + force: false, + ), + isTrue, + ); + }); + + test('refreshes when the snapshot is older than the debounce window', () { + expect( + shouldSkipRefresh( + fetchedAt: now.subtract(const Duration(minutes: 15)), + now: now, + force: false, + ), + isFalse, + ); + }); + + test('refreshes exactly at the debounce boundary', () { + expect( + shouldSkipRefresh( + fetchedAt: now.subtract(WidgetBackgroundTask.refreshDebounce), + now: now, + force: false, + ), + isFalse, + ); + }); + + test('force bypasses a fresh snapshot', () { + expect( + shouldSkipRefresh( + fetchedAt: now.subtract(const Duration(minutes: 1)), + now: now, + force: true, + ), + isFalse, + ); + }); + + test('refreshes when no snapshot exists yet', () { + expect(shouldSkipRefresh(fetchedAt: null, now: now, force: false), isFalse); + }); + + test('refreshes when fetchedAt lies in the future (clock change)', () { + expect( + shouldSkipRefresh( + fetchedAt: now.add(const Duration(minutes: 5)), + now: now, + force: false, + ), + isFalse, + ); + }); + }); +} diff --git a/test/push/push_message_handler_test.dart b/test/push/push_message_handler_test.dart index e4f694c..778bb9f 100644 --- a/test/push/push_message_handler_test.dart +++ b/test/push/push_message_handler_test.dart @@ -17,6 +17,47 @@ void main() { ); }); + test('widget refresh push identified by source + type', () { + expect( + classifyPush({'source': 'connect', 'type': 'widget-refresh'}), + PushKind.widgetRefresh, + ); + }); + + test('widget refresh push with reason still classifies', () { + expect( + classifyPush({ + 'source': 'connect', + 'type': 'widget-refresh', + 'reason': 'morning', + }), + PushKind.widgetRefresh, + ); + }); + + test('connect push with unrelated type stays connect', () { + expect( + classifyPush({'source': 'connect', 'type': 'newsletter', 'title': 'Hi'}), + PushKind.connect, + ); + }); + + test('nextcloud fields take precedence over widget-refresh type', () { + expect( + classifyPush({ + 'subject': 'enc', + 'signature': 'sig', + 'source': 'connect', + 'type': 'widget-refresh', + }), + PushKind.nextcloud, + ); + }); + + test('widget-refresh type without connect source is unknown', () { + expect(classifyPush({'type': 'widget-refresh'}), PushKind.unknown); + }); + test('subject without signature is not a nextcloud push', () { expect(classifyPush({'subject': 'enc'}), PushKind.unknown); }); diff --git a/test/widget_data/widget_data_mapper_test.dart b/test/widget_data/widget_data_mapper_test.dart index 41ac412..91fa69c 100644 --- a/test/widget_data/widget_data_mapper_test.dart +++ b/test/widget_data/widget_data_mapper_test.dart @@ -345,15 +345,32 @@ void main() { }); }); + group('startOfCalendarWeek', () { + test('Tuesday maps to its own Monday', () { + expect( + WidgetDataMapper.startOfCalendarWeek(DateTime(2026, 5, 5, 10)), + DateTime(2026, 5, 4), + ); + }); + + test('Sunday stays in the current week (no roll-forward)', () { + expect( + WidgetDataMapper.startOfCalendarWeek(DateTime(2026, 5, 10, 22)), + DateTime(2026, 5, 4), + ); + }); + }); + group('buildWeekData', () { final now = DateTime(2026, 5, 5, 10); // Tuesday - test('contains lessons across the school week', () { + 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'), ]; final data = WidgetDataMapper.buildWeekData( now: now, @@ -365,8 +382,82 @@ void main() { expect(data.anchorDate, DateTime(2026, 5, 4)); expect( data.lessons.map((l) => l.subjectShort).toList(), - ['MO', 'WE', 'FR'], + ['MO', 'WE', 'FR', 'NEXT'], ); }); + + test('window stays on the current week when the anchor rolls forward', () { + // Friday evening: day/week anchors jump to next Monday, but the window + // 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'), + ]; + final data = WidgetDataMapper.buildWeekData( + now: fridayEvening, + lessons: lessons, + subjects: null, + rooms: null, + holidays: null, + ); + expect(data.anchorDate, DateTime(2026, 5, 11)); + expect( + data.lessons.map((l) => l.subjectShort).toList(), + ['FR', 'NEXT'], + ); + }); + + test('carries per-day holiday info in days', () { + final holidays = TimetableGetHolidaysResponse( + result: [ + McHoliday( + shortName: 'Pfingsten', + longName: 'Pfingstferien', + startDate: DateTime(2026, 5, 14), + endDate: DateTime(2026, 5, 15), + ), + ], + ); + final data = WidgetDataMapper.buildWeekData( + now: now, + lessons: const [], + subjects: null, + rooms: null, + holidays: holidays, + ); + 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(); + expect(holidayDays, [DateTime(2026, 5, 14), DateTime(2026, 5, 15)]); + expect( + data.days.firstWhere((d) => d.isHoliday).holidayName, + 'Pfingstferien', + ); + }); + + test('sets top-level holiday flags for the anchor day', () { + final holidays = TimetableGetHolidaysResponse( + result: [ + McHoliday( + shortName: 'Oster', + longName: 'Osterferien', + startDate: DateTime(2026, 5, 4), + endDate: DateTime(2026, 5, 8), + ), + ], + ); + final data = WidgetDataMapper.buildWeekData( + now: now, + lessons: const [], + subjects: null, + rooms: null, + holidays: holidays, + ); + expect(data.isHoliday, isTrue); + expect(data.holidayName, 'Osterferien'); + }); }); }