widget refresh enhancements
This commit is contained in:
@@ -65,5 +65,9 @@ flutter {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'com.android.support:multidex:2.0.1'
|
implementation 'com.android.support:multidex:2.0.1'
|
||||||
|
// Same version as workmanager_android pins — needed to enqueue its
|
||||||
|
// BackgroundWorker from native widget code (the plugin uses
|
||||||
|
// `implementation`, so androidx.work is not exposed transitively).
|
||||||
|
implementation 'androidx.work:work-runtime:2.11.2'
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
|
|||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,6 +12,11 @@ import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
|||||||
* Flutter plugin resolves the receiver class as `<app-package>.<androidName>`.
|
* Flutter plugin resolves the receiver class as `<app-package>.<androidName>`.
|
||||||
*/
|
*/
|
||||||
class TimetableDayWidget : AppWidgetProvider() {
|
class TimetableDayWidget : AppWidgetProvider() {
|
||||||
|
override fun onEnabled(context: Context) {
|
||||||
|
// First widget of this kind placed → fetch fresh data right away.
|
||||||
|
WidgetRefreshRequester.enqueueOneOffRefresh(context)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import android.appwidget.AppWidgetManager
|
|||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||||
|
|
||||||
class TimetableWeekWidget : AppWidgetProvider() {
|
class TimetableWeekWidget : AppWidgetProvider() {
|
||||||
|
override fun onEnabled(context: Context) {
|
||||||
|
// First widget of this kind placed → fetch fresh data right away.
|
||||||
|
WidgetRefreshRequester.enqueueOneOffRefresh(context)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package eu.mhsl.marianum.mobile.client.widgets
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequest
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import dev.fluttercommunity.workmanager.BackgroundWorker
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues the app's Dart one-off widget refresh from native code, so a
|
||||||
|
* freshly placed widget populates within seconds instead of waiting for the
|
||||||
|
* next periodic slot.
|
||||||
|
*
|
||||||
|
* DART_TASK_KEY is public plugin API but no semver guarantee — re-verify on
|
||||||
|
* workmanager upgrades. The Dart callback handle comes from SharedPreferences
|
||||||
|
* persisted by Workmanager().initialize(); if the app never ran, the worker
|
||||||
|
* fails gracefully and the widget keeps rendering its cached/empty state.
|
||||||
|
*/
|
||||||
|
object WidgetRefreshRequester {
|
||||||
|
// Mirrors WidgetBackgroundTask.oneOffTaskName on the Dart side.
|
||||||
|
private const val DART_ONE_OFF_TASK = "eu.mhsl.marianum.widget.refresh.once"
|
||||||
|
|
||||||
|
fun enqueueOneOffRefresh(context: Context) {
|
||||||
|
val request = OneTimeWorkRequest.Builder(BackgroundWorker::class.java)
|
||||||
|
.setInputData(
|
||||||
|
Data.Builder()
|
||||||
|
.putString(BackgroundWorker.DART_TASK_KEY, DART_ONE_OFF_TASK)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueue(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -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_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_LOGGED_IN = "widget_data_logged_in_v1"
|
||||||
const val KEY_THEME_MODE = "widget_setting_theme_mode_v1"
|
const val KEY_THEME_MODE = "widget_setting_theme_mode_v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import workmanager_apple
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
@@ -23,6 +24,19 @@ import UserNotifications
|
|||||||
) -> Bool {
|
) -> Bool {
|
||||||
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
registerTalkCategory()
|
registerTalkCategory()
|
||||||
|
// BGAppRefresh for the home-screen widget. Must all happen before
|
||||||
|
// didFinishLaunching returns: with the UIScene lifecycle Flutter registers
|
||||||
|
// plugins only during scene connection, which is past BGTaskScheduler's
|
||||||
|
// registration deadline — registerLaunchHandlers() bridges that gap.
|
||||||
|
// The task identifier mirrors WidgetBackgroundTask.periodicTaskName; the
|
||||||
|
// plugin re-submits the refresh request itself after every run.
|
||||||
|
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
|
||||||
|
GeneratedPluginRegistrant.register(with: registry)
|
||||||
|
}
|
||||||
|
WorkmanagerPlugin.registerPeriodicTask(
|
||||||
|
withIdentifier: "eu.mhsl.marianum.widget.refresh",
|
||||||
|
earliestBeginInSeconds: 1800)
|
||||||
|
WorkmanagerPlugin.registerLaunchHandlers()
|
||||||
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
|
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
|
||||||
// forwards these callbacks to the plugins (firebase_messaging,
|
// forwards these callbacks to the plugins (firebase_messaging,
|
||||||
// flutter_local_notifications). We route Talk actions natively here — the
|
// flutter_local_notifications). We route Talk actions natively here — the
|
||||||
|
|||||||
@@ -66,6 +66,10 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>eu.mhsl.marianum.widget.refresh</string>
|
||||||
|
</array>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,13 +100,13 @@ struct TimetableDayView: View {
|
|||||||
|
|
||||||
private func header(data: WidgetTimetableData) -> some View {
|
private func header(data: WidgetTimetableData) -> some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(dayLabel(for: data.anchorDate))
|
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.7)
|
||||||
Spacer(minLength: 4)
|
Spacer(minLength: 4)
|
||||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 9))
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.lineLimit(1)
|
.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 .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 .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 .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()
|
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 cal = Calendar.current
|
||||||
let today = cal.startOfDay(for: Date())
|
let today = cal.startOfDay(for: now)
|
||||||
let anchor = cal.startOfDay(for: date)
|
let anchor = cal.startOfDay(for: date)
|
||||||
if anchor == today {
|
if anchor == today {
|
||||||
return "Heute · \(shortDate(date))"
|
return "Heute · \(shortDate(date))"
|
||||||
@@ -410,35 +433,23 @@ func dayLabel(for date: Date) -> String {
|
|||||||
if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
|
if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
|
||||||
return "Morgen · \(shortDate(date))"
|
return "Morgen · \(shortDate(date))"
|
||||||
}
|
}
|
||||||
let formatter = DateFormatter()
|
return WidgetDateFormatters.weekdayDate.string(from: date)
|
||||||
formatter.locale = Locale(identifier: "de_DE")
|
|
||||||
formatter.dateFormat = "EEEE · dd.MM."
|
|
||||||
return formatter.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func shortDate(_ date: Date) -> String {
|
func shortDate(_ date: Date) -> String {
|
||||||
let f = DateFormatter()
|
WidgetDateFormatters.shortDate.string(from: date)
|
||||||
f.locale = Locale(identifier: "de_DE")
|
|
||||||
f.dateFormat = "dd.MM."
|
|
||||||
return f.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func freshnessLabel(for fetchedAt: Date) -> String {
|
func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
let today = cal.startOfDay(for: Date())
|
let today = cal.startOfDay(for: now)
|
||||||
let fetchedDay = cal.startOfDay(for: fetchedAt)
|
let fetchedDay = cal.startOfDay(for: fetchedAt)
|
||||||
let timeFmt = DateFormatter()
|
|
||||||
timeFmt.locale = Locale(identifier: "de_DE")
|
|
||||||
timeFmt.dateFormat = "HH:mm"
|
|
||||||
if fetchedDay == today {
|
if fetchedDay == today {
|
||||||
return timeFmt.string(from: fetchedAt)
|
return WidgetDateFormatters.time.string(from: fetchedAt)
|
||||||
}
|
}
|
||||||
if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
|
if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
|
||||||
fetchedDay == yesterday {
|
fetchedDay == yesterday {
|
||||||
return "gestern \(timeFmt.string(from: fetchedAt))"
|
return "gestern \(WidgetDateFormatters.time.string(from: fetchedAt))"
|
||||||
}
|
}
|
||||||
let dateTimeFmt = DateFormatter()
|
return WidgetDateFormatters.dateTime.string(from: fetchedAt)
|
||||||
dateTimeFmt.locale = Locale(identifier: "de_DE")
|
|
||||||
dateTimeFmt.dateFormat = "dd.MM. HH:mm"
|
|
||||||
return dateTimeFmt.string(from: fetchedAt)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ struct TimetableWeekView: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.8)
|
.minimumScaleFactor(0.8)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 9))
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -168,10 +168,7 @@ struct TimetableWeekView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func weekday(for date: Date) -> String {
|
private func weekday(for date: Date) -> String {
|
||||||
let f = DateFormatter()
|
WidgetDateFormatters.weekdayShort.string(from: date)
|
||||||
f.locale = Locale(identifier: "de_DE")
|
|
||||||
f.dateFormat = "EE"
|
|
||||||
return f.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func placeholder(_ message: String) -> some View {
|
private func placeholder(_ message: String) -> some View {
|
||||||
|
|||||||
@@ -41,11 +41,13 @@ struct TimetableDayProvider: TimelineProvider {
|
|||||||
in context: Context,
|
in context: Context,
|
||||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
||||||
) {
|
) {
|
||||||
let entry = TimetableEntry.current(variant: .day)
|
let now = Date()
|
||||||
// 30 min mirrors the Dart workmanager cadence. iOS treats this as
|
// 30 min mirrors the Dart workmanager cadence. iOS treats this as
|
||||||
// advisory; the "Stand:" label tells the user when data is stale.
|
// advisory; the boundary entries below keep the rendered day correct
|
||||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
// even when no reload is granted, and the "Stand:" label tells the
|
||||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
// user when the underlying data is stale.
|
||||||
|
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||||
|
completion(Timeline(entries: TimetableEntry.dayEntries(now: now), policy: .after(next)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,9 +82,9 @@ struct TimetableWeekProvider: TimelineProvider {
|
|||||||
in context: Context,
|
in context: Context,
|
||||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
||||||
) {
|
) {
|
||||||
let entry = TimetableEntry.current(variant: .week)
|
let now = Date()
|
||||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +122,58 @@ struct TimetableEntry: TimelineEntry {
|
|||||||
themeMode: WidgetDataLoader.themeMode()
|
themeMode: WidgetDataLoader.themeMode()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Day timeline derived from the 14-day week payload, so the widget shows
|
||||||
|
/// the right day even when iOS grants no reload for days.
|
||||||
|
static func dayEntries(now: Date) -> [TimetableEntry] {
|
||||||
|
entries(now: now, variant: .day) { week, date in
|
||||||
|
TimetableAnchor.slice(week: week, forDay: TimetableAnchor.resolveDayAnchor(date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Week timeline: re-anchoring performs the Friday-evening/weekend jump
|
||||||
|
/// into next week from cached data, and the midnight entries keep the
|
||||||
|
/// "Stand:" freshness label honest.
|
||||||
|
static func weekEntries(now: Date) -> [TimetableEntry] {
|
||||||
|
entries(now: now, variant: .week) { week, date in
|
||||||
|
TimetableAnchor.retarget(week: week, anchorDate: TimetableAnchor.resolveWeekAnchor(date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared timeline skeleton: one entry now plus one per anchor boundary
|
||||||
|
/// (midnight rollover, 17:00 cutoff). Boundaries that cannot change the
|
||||||
|
/// render — same anchor and same calendar day for the header labels —
|
||||||
|
/// are dropped.
|
||||||
|
private static func entries(
|
||||||
|
now: Date,
|
||||||
|
variant: TimetableVariant,
|
||||||
|
transform: (WidgetTimetableData, Date) -> WidgetTimetableData
|
||||||
|
) -> [TimetableEntry] {
|
||||||
|
guard WidgetDataLoader.isLoggedIn(), let week = WidgetDataLoader.loadWeek() else {
|
||||||
|
// Logged out, or no v2 week snapshot yet (fresh app update):
|
||||||
|
// fall back to the legacy single-entry payload.
|
||||||
|
return [TimetableEntry.current(variant: variant)]
|
||||||
|
}
|
||||||
|
let theme = WidgetDataLoader.themeMode()
|
||||||
|
let cal = Calendar.current
|
||||||
|
var result: [TimetableEntry] = []
|
||||||
|
for date in [now] + TimetableAnchor.boundaryDates(from: now) {
|
||||||
|
let data = transform(week, date)
|
||||||
|
if let previous = result.last, let previousData = previous.data,
|
||||||
|
cal.isDate(previousData.anchorDate, inSameDayAs: data.anchorDate),
|
||||||
|
cal.isDate(previous.date, inSameDayAs: date) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.append(TimetableEntry(
|
||||||
|
date: date,
|
||||||
|
variant: variant,
|
||||||
|
data: data,
|
||||||
|
isLoggedIn: true,
|
||||||
|
themeMode: theme
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ enum WidgetLessonStatus: String, Codable {
|
|||||||
case irregular
|
case irregular
|
||||||
case teacherChanged
|
case teacherChanged
|
||||||
case event
|
case event
|
||||||
|
case duty
|
||||||
|
|
||||||
|
/// Unknown future statuses degrade to `.regular` instead of failing the
|
||||||
|
/// whole payload decode (mirrors WidgetData.kt's fromWire fallback) — a
|
||||||
|
/// single new enum value must never blank the widget to the placeholder.
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let raw = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
self = WidgetLessonStatus(rawValue: raw) ?? .regular
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WidgetLesson: Codable {
|
struct WidgetLesson: Codable {
|
||||||
@@ -33,6 +42,12 @@ struct WidgetPeriod: Codable {
|
|||||||
let virtualEndMinutes: Int
|
let virtualEndMinutes: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct WidgetDayInfo: Codable {
|
||||||
|
let date: Date
|
||||||
|
let isHoliday: Bool
|
||||||
|
let holidayName: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct WidgetTimetableData: Codable {
|
struct WidgetTimetableData: Codable {
|
||||||
let fetchedAt: Date
|
let fetchedAt: Date
|
||||||
let anchorDate: Date
|
let anchorDate: Date
|
||||||
@@ -40,12 +55,17 @@ struct WidgetTimetableData: Codable {
|
|||||||
let periods: [WidgetPeriod]
|
let periods: [WidgetPeriod]
|
||||||
let isHoliday: Bool
|
let isHoliday: Bool
|
||||||
let holidayName: String?
|
let holidayName: String?
|
||||||
|
/// Week payload (v2) only; optional so day payloads keep decoding.
|
||||||
|
let days: [WidgetDayInfo]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a
|
||||||
|
/// schema bump must land in Dart, Kotlin (WidgetRenderer.kt) and here
|
||||||
|
/// together, or the out-of-sync platform silently blanks to the placeholder.
|
||||||
enum WidgetDataKey {
|
enum WidgetDataKey {
|
||||||
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
|
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
|
||||||
static let dayData = "widget_data_day_v1"
|
static let dayData = "widget_data_day_v1"
|
||||||
static let weekData = "widget_data_week_v1"
|
static let weekData = "widget_data_week_v2"
|
||||||
static let loggedIn = "widget_data_logged_in_v1"
|
static let loggedIn = "widget_data_logged_in_v1"
|
||||||
static let themeMode = "widget_setting_theme_mode_v1"
|
static let themeMode = "widget_setting_theme_mode_v1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:workmanager/workmanager.dart';
|
import 'package:workmanager/workmanager.dart';
|
||||||
@@ -32,12 +33,20 @@ class WidgetBackgroundTask {
|
|||||||
|
|
||||||
static const Duration periodicFrequency = Duration(minutes: 30);
|
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<void> initialize() async {
|
static Future<void> initialize() async {
|
||||||
await Workmanager().initialize(_callbackDispatcher);
|
await Workmanager().initialize(_callbackDispatcher);
|
||||||
await Workmanager().registerPeriodicTask(
|
await Workmanager().registerPeriodicTask(
|
||||||
periodicTaskName,
|
periodicTaskName,
|
||||||
periodicTaskName,
|
periodicTaskName,
|
||||||
frequency: periodicFrequency,
|
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),
|
constraints: Constraints(networkType: NetworkType.connected),
|
||||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
||||||
backoffPolicy: BackoffPolicy.linear,
|
backoffPolicy: BackoffPolicy.linear,
|
||||||
@@ -45,7 +54,21 @@ class WidgetBackgroundTask {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> 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<void> 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(
|
await Workmanager().registerOneOffTask(
|
||||||
'$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}',
|
'$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
oneOffTaskName,
|
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<void> 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<void> cancelAll() async {
|
static Future<void> cancelAll() async {
|
||||||
await Workmanager().cancelAll();
|
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')
|
@pragma('vm:entry-point')
|
||||||
void _callbackDispatcher() {
|
void _callbackDispatcher() {
|
||||||
Workmanager().executeTask((task, inputData) async {
|
Workmanager().executeTask((task, inputData) async {
|
||||||
try {
|
try {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await AccountData().waitForPopulation();
|
await WidgetBackgroundTask.runRefreshNow();
|
||||||
if (!AccountData().isPopulated()) {
|
|
||||||
log('[widget-bg] not logged in, skipping refresh');
|
|
||||||
await WidgetSync.setLoggedIn(false);
|
|
||||||
await WidgetSync.triggerUpdate();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
await _refresh();
|
|
||||||
return true;
|
return true;
|
||||||
} on Exception catch (e, s) {
|
} on Exception catch (e, s) {
|
||||||
log('[widget-bg] refresh failed: $e', stackTrace: s);
|
log('[widget-bg] refresh failed: $e', stackTrace: s);
|
||||||
@@ -94,35 +154,44 @@ Future<void> _refresh() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final now = WidgetPublisher.widgetNow();
|
final now = WidgetPublisher.widgetNow();
|
||||||
// 14-day window so the week-widget rolls forward into next Monday's
|
// Fetch window matches the week payload's window so the widget can roll
|
||||||
// lessons on Friday evening.
|
// forward into next week's lessons without fresh data.
|
||||||
final weekStart = _startOfWeek(now);
|
final weekStart = WidgetDataMapper.startOfCalendarWeek(now);
|
||||||
final weekEndExclusive = weekStart.add(const Duration(days: 14));
|
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,
|
from: weekStart,
|
||||||
until: weekEndExclusive.subtract(const Duration(days: 1)),
|
until: weekEndExclusive.subtract(const Duration(days: 1)),
|
||||||
);
|
);
|
||||||
|
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
|
||||||
// Reference data — failures fall through to null in the mapper rather
|
|
||||||
// than aborting the whole refresh.
|
|
||||||
final subjects = await _runOrNull<TimetableGetSubjectsResponse>(
|
|
||||||
() => TimetableGetSubjects().run(),
|
() => TimetableGetSubjects().run(),
|
||||||
);
|
);
|
||||||
final rooms = await _runOrNull<TimetableGetRoomsResponse>(
|
final roomsFuture = _runOrNull<TimetableGetRoomsResponse>(
|
||||||
() => TimetableGetRooms().run(),
|
() => TimetableGetRooms().run(),
|
||||||
);
|
);
|
||||||
final holidays = await _runOrNull<TimetableGetHolidaysResponse>(
|
final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>(
|
||||||
() => TimetableGetHolidays().run(),
|
() => TimetableGetHolidays().run(),
|
||||||
);
|
);
|
||||||
final timegrid = await _runOrNull<TimetableGetTimegridResponse>(
|
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
|
||||||
() => TimetableGetTimegrid().run(),
|
() => TimetableGetTimegrid().run(),
|
||||||
);
|
);
|
||||||
final customEvents = await _runOrNull<GetCustomTimetableEventResponse>(
|
final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>(
|
||||||
() => GetCustomTimetableEvent(
|
() => GetCustomTimetableEvent(
|
||||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
||||||
).run(),
|
).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;
|
final lessons = timetable.entries;
|
||||||
|
|
||||||
@@ -158,11 +227,6 @@ Future<void> _refresh() async {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
DateTime _startOfWeek(DateTime reference) {
|
|
||||||
final monday = reference.subtract(Duration(days: reference.weekday - 1));
|
|
||||||
return DateTime(monday.year, monday.month, monday.day);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<T?> _runOrNull<T>(Future<T> Function() task) async {
|
Future<T?> _runOrNull<T>(Future<T> Function() task) async {
|
||||||
try {
|
try {
|
||||||
return await task();
|
return await task();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:developer';
|
|||||||
import 'package:crypton/crypton.dart';
|
import 'package:crypton/crypton.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
|
||||||
|
import '../background/widget_background_task.dart';
|
||||||
import '../notification/notification_service.dart';
|
import '../notification/notification_service.dart';
|
||||||
import 'chat_thread_store.dart';
|
import 'chat_thread_store.dart';
|
||||||
import 'nid_store.dart';
|
import 'nid_store.dart';
|
||||||
@@ -12,6 +13,10 @@ import 'push_registration_store.dart';
|
|||||||
import 'push_renderer.dart';
|
import 'push_renderer.dart';
|
||||||
import 'push_subject.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.
|
/// How an incoming FCM payload should be interpreted.
|
||||||
enum PushKind {
|
enum PushKind {
|
||||||
/// Encrypted Nextcloud push-v2 notification (`subject` + `signature`).
|
/// Encrypted Nextcloud push-v2 notification (`subject` + `signature`).
|
||||||
@@ -20,6 +25,11 @@ enum PushKind {
|
|||||||
/// Plaintext MarianumConnect direct push (`source == "connect"`).
|
/// Plaintext MarianumConnect direct push (`source == "connect"`).
|
||||||
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.
|
/// Neither — ignored.
|
||||||
unknown,
|
unknown,
|
||||||
}
|
}
|
||||||
@@ -31,7 +41,10 @@ PushKind classifyPush(Map<String, dynamic> data) {
|
|||||||
final hasSubject = (data['subject'] as String?)?.isNotEmpty ?? false;
|
final hasSubject = (data['subject'] as String?)?.isNotEmpty ?? false;
|
||||||
final hasSignature = (data['signature'] as String?)?.isNotEmpty ?? false;
|
final hasSignature = (data['signature'] as String?)?.isNotEmpty ?? false;
|
||||||
if (hasSubject && hasSignature) return PushKind.nextcloud;
|
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;
|
return PushKind.unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +110,30 @@ class PushMessageHandler {
|
|||||||
notificationsEnabled: notificationsEnabled,
|
notificationsEnabled: notificationsEnabled,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case PushKind.widgetRefresh:
|
||||||
|
// Deliberately before any notificationsEnabled gate: silent sync
|
||||||
|
// pushes must work with notifications off.
|
||||||
|
await _handleWidgetRefresh();
|
||||||
|
break;
|
||||||
case PushKind.unknown:
|
case PushKind.unknown:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _handleConnect(
|
Future<void> _handleConnect(
|
||||||
RemoteMessage message, {
|
RemoteMessage message, {
|
||||||
required bool foreground,
|
required bool foreground,
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ abstract class WidgetPeriod with _$WidgetPeriod {
|
|||||||
_$WidgetPeriodFromJson(json);
|
_$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<String, Object?> json) =>
|
||||||
|
_$WidgetDayInfoFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class WidgetTimetableData with _$WidgetTimetableData {
|
abstract class WidgetTimetableData with _$WidgetTimetableData {
|
||||||
const factory WidgetTimetableData({
|
const factory WidgetTimetableData({
|
||||||
@@ -73,6 +87,8 @@ abstract class WidgetTimetableData with _$WidgetTimetableData {
|
|||||||
@Default(<WidgetPeriod>[]) List<WidgetPeriod> periods,
|
@Default(<WidgetPeriod>[]) List<WidgetPeriod> periods,
|
||||||
@Default(false) bool isHoliday,
|
@Default(false) bool isHoliday,
|
||||||
String? holidayName,
|
String? holidayName,
|
||||||
|
/// Week payload only: one entry per day of the covered window.
|
||||||
|
@Default(<WidgetDayInfo>[]) List<WidgetDayInfo> days,
|
||||||
}) = _WidgetTimetableData;
|
}) = _WidgetTimetableData;
|
||||||
|
|
||||||
factory WidgetTimetableData.fromJson(Map<String, Object?> json) =>
|
factory WidgetTimetableData.fromJson(Map<String, Object?> json) =>
|
||||||
|
|||||||
@@ -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<WidgetDayInfo> get copyWith => _$WidgetDayInfoCopyWithImpl<WidgetDayInfo>(this as WidgetDayInfo, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this WidgetDayInfo to a JSON map.
|
||||||
|
Map<String, dynamic> 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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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<String, dynamic> 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<String, dynamic> 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
|
/// @nodoc
|
||||||
mixin _$WidgetTimetableData {
|
mixin _$WidgetTimetableData {
|
||||||
|
|
||||||
DateTime get fetchedAt;/// The day this widget snapshot is "about" — display anchor.
|
DateTime get fetchedAt;/// The day this widget snapshot is "about" — display anchor.
|
||||||
/// For the day variant: the rendered school day.
|
/// For the day variant: the rendered school day.
|
||||||
/// For the week variant: the Monday of the rendered school week.
|
/// For the week variant: the Monday of the rendered school week.
|
||||||
DateTime get anchorDate; List<WidgetLesson> get lessons; List<WidgetPeriod> get periods; bool get isHoliday; String? get holidayName;
|
DateTime get anchorDate; List<WidgetLesson> get lessons; List<WidgetPeriod> get periods; bool get isHoliday; String? get holidayName;/// Week payload only: one entry per day of the covered window.
|
||||||
|
List<WidgetDayInfo> get days;
|
||||||
/// Create a copy of WidgetTimetableData
|
/// Create a copy of WidgetTimetableData
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -612,16 +882,16 @@ $WidgetTimetableDataCopyWith<WidgetTimetableData> get copyWith => _$WidgetTimeta
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory $WidgetTimetableDataCopyWith(WidgetTimetableData value, $Res Function(WidgetTimetableData) _then) = _$WidgetTimetableDataCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName
|
DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName, List<WidgetDayInfo> days
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -649,7 +919,7 @@ class _$WidgetTimetableDataCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of WidgetTimetableData
|
/// Create a copy of WidgetTimetableData
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_self.copyWith(
|
||||||
fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable
|
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,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<WidgetLesson>,periods: null == periods ? _self.periods : periods // ignore: cast_nullable_to_non_nullable
|
as List<WidgetLesson>,periods: null == periods ? _self.periods : periods // ignore: cast_nullable_to_non_nullable
|
||||||
as List<WidgetPeriod>,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable
|
as List<WidgetPeriod>,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 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<WidgetDayInfo>,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,10 +1013,10 @@ return $default(_that);case _:
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName)? $default,{required TResult orElse(),}) {final _that = this;
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName, List<WidgetDayInfo> days)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _WidgetTimetableData() when $default != null:
|
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();
|
return orElse();
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -763,10 +1034,10 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName) $default,) {final _that = this;
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName, List<WidgetDayInfo> days) $default,) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _WidgetTimetableData():
|
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');
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -783,10 +1054,10 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName)? $default,) {final _that = this;
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName, List<WidgetDayInfo> days)? $default,) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _WidgetTimetableData() when $default != null:
|
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;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -798,7 +1069,7 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th
|
|||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|
||||||
class _WidgetTimetableData implements WidgetTimetableData {
|
class _WidgetTimetableData implements WidgetTimetableData {
|
||||||
const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required final List<WidgetLesson> lessons, final List<WidgetPeriod> periods = const <WidgetPeriod>[], this.isHoliday = false, this.holidayName}): _lessons = lessons,_periods = periods;
|
const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required final List<WidgetLesson> lessons, final List<WidgetPeriod> periods = const <WidgetPeriod>[], this.isHoliday = false, this.holidayName, final List<WidgetDayInfo> days = const <WidgetDayInfo>[]}): _lessons = lessons,_periods = periods,_days = days;
|
||||||
factory _WidgetTimetableData.fromJson(Map<String, dynamic> json) => _$WidgetTimetableDataFromJson(json);
|
factory _WidgetTimetableData.fromJson(Map<String, dynamic> json) => _$WidgetTimetableDataFromJson(json);
|
||||||
|
|
||||||
@override final DateTime fetchedAt;
|
@override final DateTime fetchedAt;
|
||||||
@@ -822,6 +1093,15 @@ class _WidgetTimetableData implements WidgetTimetableData {
|
|||||||
|
|
||||||
@override@JsonKey() final bool isHoliday;
|
@override@JsonKey() final bool isHoliday;
|
||||||
@override final String? holidayName;
|
@override final String? holidayName;
|
||||||
|
/// Week payload only: one entry per day of the covered window.
|
||||||
|
final List<WidgetDayInfo> _days;
|
||||||
|
/// Week payload only: one entry per day of the covered window.
|
||||||
|
@override@JsonKey() List<WidgetDayInfo> get days {
|
||||||
|
if (_days is EqualUnmodifiableListView) return _days;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(_days);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Create a copy of WidgetTimetableData
|
/// Create a copy of WidgetTimetableData
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@@ -836,16 +1116,16 @@ Map<String, dynamic> toJson() {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
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)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@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
|
@override
|
||||||
String toString() {
|
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;
|
factory _$WidgetTimetableDataCopyWith(_WidgetTimetableData value, $Res Function(_WidgetTimetableData) _then) = __$WidgetTimetableDataCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName
|
DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName, List<WidgetDayInfo> days
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -873,7 +1153,7 @@ class __$WidgetTimetableDataCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of WidgetTimetableData
|
/// Create a copy of WidgetTimetableData
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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(
|
return _then(_WidgetTimetableData(
|
||||||
fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable
|
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,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<WidgetLesson>,periods: null == periods ? _self._periods : periods // ignore: cast_nullable_to_non_nullable
|
as List<WidgetLesson>,periods: null == periods ? _self._periods : periods // ignore: cast_nullable_to_non_nullable
|
||||||
as List<WidgetPeriod>,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable
|
as List<WidgetPeriod>,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 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<WidgetDayInfo>,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ Map<String, dynamic> _$WidgetPeriodToJson(_WidgetPeriod instance) =>
|
|||||||
'virtualEndMinutes': instance.virtualEndMinutes,
|
'virtualEndMinutes': instance.virtualEndMinutes,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_WidgetDayInfo _$WidgetDayInfoFromJson(Map<String, dynamic> json) =>
|
||||||
|
_WidgetDayInfo(
|
||||||
|
date: DateTime.parse(json['date'] as String),
|
||||||
|
isHoliday: json['isHoliday'] as bool? ?? false,
|
||||||
|
holidayName: json['holidayName'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$WidgetDayInfoToJson(_WidgetDayInfo instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'date': instance.date.toIso8601String(),
|
||||||
|
'isHoliday': instance.isHoliday,
|
||||||
|
'holidayName': instance.holidayName,
|
||||||
|
};
|
||||||
|
|
||||||
_WidgetTimetableData _$WidgetTimetableDataFromJson(Map<String, dynamic> json) =>
|
_WidgetTimetableData _$WidgetTimetableDataFromJson(Map<String, dynamic> json) =>
|
||||||
_WidgetTimetableData(
|
_WidgetTimetableData(
|
||||||
fetchedAt: DateTime.parse(json['fetchedAt'] as String),
|
fetchedAt: DateTime.parse(json['fetchedAt'] as String),
|
||||||
@@ -77,6 +91,11 @@ _WidgetTimetableData _$WidgetTimetableDataFromJson(Map<String, dynamic> json) =>
|
|||||||
const <WidgetPeriod>[],
|
const <WidgetPeriod>[],
|
||||||
isHoliday: json['isHoliday'] as bool? ?? false,
|
isHoliday: json['isHoliday'] as bool? ?? false,
|
||||||
holidayName: json['holidayName'] as String?,
|
holidayName: json['holidayName'] as String?,
|
||||||
|
days:
|
||||||
|
(json['days'] as List<dynamic>?)
|
||||||
|
?.map((e) => WidgetDayInfo.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
const <WidgetDayInfo>[],
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$WidgetTimetableDataToJson(
|
Map<String, dynamic> _$WidgetTimetableDataToJson(
|
||||||
@@ -88,4 +107,5 @@ Map<String, dynamic> _$WidgetTimetableDataToJson(
|
|||||||
'periods': instance.periods,
|
'periods': instance.periods,
|
||||||
'isHoliday': instance.isHoliday,
|
'isHoliday': instance.isHoliday,
|
||||||
'holidayName': instance.holidayName,
|
'holidayName': instance.holidayName,
|
||||||
|
'days': instance.days,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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/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/custom_timetable_event.dart';
|
||||||
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
||||||
|
import '../extensions/date_time.dart';
|
||||||
import '../view/pages/timetable/data/lesson_merger.dart';
|
import '../view/pages/timetable/data/lesson_merger.dart';
|
||||||
import '../view/pages/timetable/data/lesson_period_schedule.dart';
|
import '../view/pages/timetable/data/lesson_period_schedule.dart';
|
||||||
import '../view/pages/timetable/data/lesson_status.dart';
|
import '../view/pages/timetable/data/lesson_status.dart';
|
||||||
@@ -34,12 +35,20 @@ class WidgetDataMapper {
|
|||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
static DateTime resolveWeekAnchor(DateTime now) {
|
static DateTime resolveWeekAnchor(DateTime now) =>
|
||||||
final anchor = resolveDayAnchor(now);
|
startOfCalendarWeek(resolveDayAnchor(now));
|
||||||
final monday = anchor.subtract(Duration(days: anchor.weekday - 1));
|
|
||||||
|
/// 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);
|
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({
|
static WidgetTimetableData buildDayData({
|
||||||
required DateTime now,
|
required DateTime now,
|
||||||
required Iterable<McTimetableEntry> lessons,
|
required Iterable<McTimetableEntry> lessons,
|
||||||
@@ -83,10 +92,14 @@ class WidgetDataMapper {
|
|||||||
bool connectDoubleLessons = true,
|
bool connectDoubleLessons = true,
|
||||||
}) {
|
}) {
|
||||||
final anchor = resolveWeekAnchor(now);
|
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 weekLessons = lessons.where((l) {
|
||||||
final dt = l.startDateTime;
|
final dt = l.startDateTime;
|
||||||
return !dt.isBefore(anchor) && dt.isBefore(endExclusive);
|
return !dt.isBefore(windowStart) && dt.isBefore(endExclusive);
|
||||||
}).toList();
|
}).toList();
|
||||||
// Per-day merge: otherwise a 4th-period lesson on Mon would collapse with
|
// Per-day merge: otherwise a 4th-period lesson on Mon would collapse with
|
||||||
// a 1st-period lesson on Tue if subject/teacher match.
|
// a 1st-period lesson on Tue if subject/teacher match.
|
||||||
@@ -95,13 +108,38 @@ class WidgetDataMapper {
|
|||||||
: weekLessons;
|
: weekLessons;
|
||||||
final mapped = <WidgetLesson>[
|
final mapped = <WidgetLesson>[
|
||||||
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
|
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
|
||||||
..._expandCustomEvents(customEvents, anchor, endExclusive),
|
..._expandCustomEvents(customEvents, windowStart, endExclusive),
|
||||||
]..sort((a, b) => a.start.compareTo(b.start));
|
]..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(
|
return WidgetTimetableData(
|
||||||
fetchedAt: now,
|
fetchedAt: now,
|
||||||
anchorDate: anchor,
|
anchorDate: anchor,
|
||||||
lessons: _resolveCollisions(mapped),
|
lessons: _resolveCollisions(mapped),
|
||||||
periods: _resolvePeriods(timegrid),
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,18 @@ class WidgetSync {
|
|||||||
static const String iosAppGroupId =
|
static const String iosAppGroupId =
|
||||||
'group.eu.mhsl.marianum.mobile.client.widget';
|
'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 androidDayProvider = 'TimetableDayWidget';
|
||||||
static const String androidWeekProvider = 'TimetableWeekWidget';
|
static const String androidWeekProvider = 'TimetableWeekWidget';
|
||||||
|
|
||||||
// `_v1` suffix lets a future schema change invalidate stale snapshots
|
// Version suffix lets a schema change invalidate stale snapshots by
|
||||||
// by bumping the key instead of risking a parse crash.
|
// bumping the key instead of risking a parse crash.
|
||||||
static const String dayDataKey = 'widget_data_day_v1';
|
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 fetchedAtKey = 'widget_data_fetched_at_v1';
|
||||||
static const String loggedInKey = 'widget_data_logged_in_v1';
|
static const String loggedInKey = 'widget_data_logged_in_v1';
|
||||||
// Mirrored into widget storage so the background isolate can read it
|
// Mirrored into widget storage so the background isolate can read it
|
||||||
@@ -90,6 +94,12 @@ class WidgetSync {
|
|||||||
return HomeWidget.getWidgetData<String>(marianumConnectBaseUrlKey);
|
return HomeWidget.getWidgetData<String>(marianumConnectBaseUrlKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<DateTime?> getFetchedAt() async {
|
||||||
|
await ensureInitialized();
|
||||||
|
final raw = await HomeWidget.getWidgetData<String>(fetchedAtKey);
|
||||||
|
return raw == null ? null : DateTime.tryParse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> clear() async {
|
static Future<void> clear() async {
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
await HomeWidget.saveWidgetData<String>(dayDataKey, null);
|
await HomeWidget.saveWidgetData<String>(dayDataKey, null);
|
||||||
@@ -103,11 +113,11 @@ class WidgetSync {
|
|||||||
try {
|
try {
|
||||||
await HomeWidget.updateWidget(
|
await HomeWidget.updateWidget(
|
||||||
androidName: androidDayProvider,
|
androidName: androidDayProvider,
|
||||||
iOSName: iosWidgetKind,
|
iOSName: iosDayWidgetKind,
|
||||||
);
|
);
|
||||||
await HomeWidget.updateWidget(
|
await HomeWidget.updateWidget(
|
||||||
androidName: androidWeekProvider,
|
androidName: androidWeekProvider,
|
||||||
iOSName: iosWidgetKind,
|
iOSName: iosWeekWidgetKind,
|
||||||
);
|
);
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
log('WidgetSync.triggerUpdate failed: $e');
|
log('WidgetSync.triggerUpdate failed: $e');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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', () {
|
test('subject without signature is not a nextcloud push', () {
|
||||||
expect(classifyPush({'subject': 'enc'}), PushKind.unknown);
|
expect(classifyPush({'subject': 'enc'}), PushKind.unknown);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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', () {
|
group('buildWeekData', () {
|
||||||
final now = DateTime(2026, 5, 5, 10); // Tuesday
|
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 = [
|
final lessons = [
|
||||||
_lesson(date: DateTime(2026, 5, 4), startHhmm: 800, endHhmm: 845, subjectName: 'MO'),
|
_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, 6), startHhmm: 800, endHhmm: 845, subjectName: 'WE'),
|
||||||
_lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'),
|
_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, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'),
|
||||||
|
_lesson(date: DateTime(2026, 5, 18), startHhmm: 800, endHhmm: 845, subjectName: 'FAR'),
|
||||||
];
|
];
|
||||||
final data = WidgetDataMapper.buildWeekData(
|
final data = WidgetDataMapper.buildWeekData(
|
||||||
now: now,
|
now: now,
|
||||||
@@ -365,8 +382,82 @@ void main() {
|
|||||||
expect(data.anchorDate, DateTime(2026, 5, 4));
|
expect(data.anchorDate, DateTime(2026, 5, 4));
|
||||||
expect(
|
expect(
|
||||||
data.lessons.map((l) => l.subjectShort).toList(),
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user