Compare commits
11 Commits
778c473631
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| ccb22a497d | |||
| 889d8f67c5 | |||
| 39c16bd4ea | |||
| b9cb1df473 | |||
| 62fa337188 | |||
| c0dadf8b6e | |||
| ab23422a86 | |||
| 646e2c0451 | |||
| 246cb0f527 | |||
| 4c2e9b47e7 | |||
| 2d690736e3 |
@@ -65,5 +65,9 @@ flutter {
|
||||
|
||||
dependencies {
|
||||
implementation 'com.android.support:multidex:2.0.1'
|
||||
// Same version as workmanager_android pins — needed to enqueue its
|
||||
// BackgroundWorker from native widget code (the plugin uses
|
||||
// `implementation`, so androidx.work is not exposed transitively).
|
||||
implementation 'androidx.work:work-runtime:2.11.2'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||
|
||||
/**
|
||||
@@ -11,6 +12,11 @@ import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||
* Flutter plugin resolves the receiver class as `<app-package>.<androidName>`.
|
||||
*/
|
||||
class TimetableDayWidget : AppWidgetProvider() {
|
||||
override fun onEnabled(context: Context) {
|
||||
// First widget of this kind placed → fetch fresh data right away.
|
||||
WidgetRefreshRequester.enqueueOneOffRefresh(context)
|
||||
}
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
|
||||
@@ -4,9 +4,15 @@ import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRefreshRequester
|
||||
import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
|
||||
|
||||
class TimetableWeekWidget : AppWidgetProvider() {
|
||||
override fun onEnabled(context: Context) {
|
||||
// First widget of this kind placed → fetch fresh data right away.
|
||||
WidgetRefreshRequester.enqueueOneOffRefresh(context)
|
||||
}
|
||||
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
|
||||
@@ -33,6 +33,8 @@ data class WidgetLesson(
|
||||
val subjectShort: String,
|
||||
val subjectLong: String?,
|
||||
val room: String?,
|
||||
// On teacher accounts this carries the class label ("7a") instead of the
|
||||
// teacher short name (originalTeacher is null then) — mapped in Dart.
|
||||
val teacher: String?,
|
||||
val originalTeacher: String?,
|
||||
val status: WidgetLessonStatus,
|
||||
|
||||
+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_WEEK_DATA = "widget_data_week_v1"
|
||||
const val KEY_WEEK_DATA = "widget_data_week_v2"
|
||||
const val KEY_LOGGED_IN = "widget_data_logged_in_v1"
|
||||
const val KEY_THEME_MODE = "widget_setting_theme_mode_v1"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import workmanager_apple
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
@@ -23,6 +24,19 @@ import UserNotifications
|
||||
) -> Bool {
|
||||
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
registerTalkCategory()
|
||||
// BGAppRefresh for the home-screen widget. Must all happen before
|
||||
// didFinishLaunching returns: with the UIScene lifecycle Flutter registers
|
||||
// plugins only during scene connection, which is past BGTaskScheduler's
|
||||
// registration deadline — registerLaunchHandlers() bridges that gap.
|
||||
// The task identifier mirrors WidgetBackgroundTask.periodicTaskName; the
|
||||
// plugin re-submits the refresh request itself after every run.
|
||||
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
|
||||
GeneratedPluginRegistrant.register(with: registry)
|
||||
}
|
||||
WorkmanagerPlugin.registerPeriodicTask(
|
||||
withIdentifier: "eu.mhsl.marianum.widget.refresh",
|
||||
earliestBeginInSeconds: 1800)
|
||||
WorkmanagerPlugin.registerLaunchHandlers()
|
||||
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
|
||||
// forwards these callbacks to the plugins (firebase_messaging,
|
||||
// flutter_local_notifications). We route Talk actions natively here — the
|
||||
|
||||
@@ -66,6 +66,10 @@
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>eu.mhsl.marianum.widget.refresh</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<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 {
|
||||
HStack(spacing: 4) {
|
||||
Text(dayLabel(for: data.anchorDate))
|
||||
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Spacer(minLength: 4)
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
@@ -383,6 +383,7 @@ struct TimeGridView: View {
|
||||
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
|
||||
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
|
||||
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
|
||||
case .duty: return Color(red: 0/255.0, green: 121/255.0, blue: 107/255.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,9 +401,31 @@ func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
|
||||
return result.sorted()
|
||||
}
|
||||
|
||||
func dayLabel(for date: Date) -> String {
|
||||
/// Fixed-locale formatters cached once — DateFormatter setup is the
|
||||
/// expensive part and the multi-entry timelines render up to ~8 entries per
|
||||
/// reload. Only touched from WidgetKit's archival rendering, so the shared
|
||||
/// instances are safe.
|
||||
enum WidgetDateFormatters {
|
||||
static let shortDate = make("dd.MM.")
|
||||
static let weekdayShort = make("EE")
|
||||
static let weekdayDate = make("EEEE · dd.MM.")
|
||||
static let time = make("HH:mm")
|
||||
static let dateTime = make("dd.MM. HH:mm")
|
||||
|
||||
private static func make(_ format: String) -> DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = format
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
/// `now` is the timeline entry's date, not `Date()`: WidgetKit archives
|
||||
/// entries ahead of time, so wall-clock reads would be wrong for every
|
||||
/// entry after the first.
|
||||
func dayLabel(for date: Date, relativeTo now: Date) -> String {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let today = cal.startOfDay(for: now)
|
||||
let anchor = cal.startOfDay(for: date)
|
||||
if anchor == today {
|
||||
return "Heute · \(shortDate(date))"
|
||||
@@ -410,35 +433,23 @@ func dayLabel(for date: Date) -> String {
|
||||
if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
|
||||
return "Morgen · \(shortDate(date))"
|
||||
}
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "de_DE")
|
||||
formatter.dateFormat = "EEEE · dd.MM."
|
||||
return formatter.string(from: date)
|
||||
return WidgetDateFormatters.weekdayDate.string(from: date)
|
||||
}
|
||||
|
||||
func shortDate(_ date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = "dd.MM."
|
||||
return f.string(from: date)
|
||||
WidgetDateFormatters.shortDate.string(from: date)
|
||||
}
|
||||
|
||||
func freshnessLabel(for fetchedAt: Date) -> String {
|
||||
func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let today = cal.startOfDay(for: now)
|
||||
let fetchedDay = cal.startOfDay(for: fetchedAt)
|
||||
let timeFmt = DateFormatter()
|
||||
timeFmt.locale = Locale(identifier: "de_DE")
|
||||
timeFmt.dateFormat = "HH:mm"
|
||||
if fetchedDay == today {
|
||||
return timeFmt.string(from: fetchedAt)
|
||||
return WidgetDateFormatters.time.string(from: fetchedAt)
|
||||
}
|
||||
if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
|
||||
fetchedDay == yesterday {
|
||||
return "gestern \(timeFmt.string(from: fetchedAt))"
|
||||
return "gestern \(WidgetDateFormatters.time.string(from: fetchedAt))"
|
||||
}
|
||||
let dateTimeFmt = DateFormatter()
|
||||
dateTimeFmt.locale = Locale(identifier: "de_DE")
|
||||
dateTimeFmt.dateFormat = "dd.MM. HH:mm"
|
||||
return dateTimeFmt.string(from: fetchedAt)
|
||||
return WidgetDateFormatters.dateTime.string(from: fetchedAt)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ struct TimetableWeekView: View {
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
Spacer()
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
@@ -168,10 +168,7 @@ struct TimetableWeekView: View {
|
||||
}
|
||||
|
||||
private func weekday(for date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = "EE"
|
||||
return f.string(from: date)
|
||||
WidgetDateFormatters.weekdayShort.string(from: date)
|
||||
}
|
||||
|
||||
private func placeholder(_ message: String) -> some View {
|
||||
|
||||
@@ -41,11 +41,13 @@ struct TimetableDayProvider: TimelineProvider {
|
||||
in context: Context,
|
||||
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
|
||||
// advisory; the "Stand:" label tells the user when data is stale.
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
// advisory; the boundary entries below keep the rendered day correct
|
||||
// even when no reload is granted, and the "Stand:" label tells the
|
||||
// user when the underlying data is stale.
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||
completion(Timeline(entries: TimetableEntry.dayEntries(now: now), policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +82,9 @@ struct TimetableWeekProvider: TimelineProvider {
|
||||
in context: Context,
|
||||
completion: @escaping (Timeline<TimetableEntry>) -> Void
|
||||
) {
|
||||
let entry = TimetableEntry.current(variant: .week)
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
let now = Date()
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||
completion(Timeline(entries: TimetableEntry.weekEntries(now: now), policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +122,58 @@ struct TimetableEntry: TimelineEntry {
|
||||
themeMode: WidgetDataLoader.themeMode()
|
||||
)
|
||||
}
|
||||
|
||||
/// Day timeline derived from the 14-day week payload, so the widget shows
|
||||
/// the right day even when iOS grants no reload for days.
|
||||
static func dayEntries(now: Date) -> [TimetableEntry] {
|
||||
entries(now: now, variant: .day) { week, date in
|
||||
TimetableAnchor.slice(week: week, forDay: TimetableAnchor.resolveDayAnchor(date))
|
||||
}
|
||||
}
|
||||
|
||||
/// Week timeline: re-anchoring performs the Friday-evening/weekend jump
|
||||
/// into next week from cached data, and the midnight entries keep the
|
||||
/// "Stand:" freshness label honest.
|
||||
static func weekEntries(now: Date) -> [TimetableEntry] {
|
||||
entries(now: now, variant: .week) { week, date in
|
||||
TimetableAnchor.retarget(week: week, anchorDate: TimetableAnchor.resolveWeekAnchor(date))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared timeline skeleton: one entry now plus one per anchor boundary
|
||||
/// (midnight rollover, 17:00 cutoff). Boundaries that cannot change the
|
||||
/// render — same anchor and same calendar day for the header labels —
|
||||
/// are dropped.
|
||||
private static func entries(
|
||||
now: Date,
|
||||
variant: TimetableVariant,
|
||||
transform: (WidgetTimetableData, Date) -> WidgetTimetableData
|
||||
) -> [TimetableEntry] {
|
||||
guard WidgetDataLoader.isLoggedIn(), let week = WidgetDataLoader.loadWeek() else {
|
||||
// Logged out, or no v2 week snapshot yet (fresh app update):
|
||||
// fall back to the legacy single-entry payload.
|
||||
return [TimetableEntry.current(variant: variant)]
|
||||
}
|
||||
let theme = WidgetDataLoader.themeMode()
|
||||
let cal = Calendar.current
|
||||
var result: [TimetableEntry] = []
|
||||
for date in [now] + TimetableAnchor.boundaryDates(from: now) {
|
||||
let data = transform(week, date)
|
||||
if let previous = result.last, let previousData = previous.data,
|
||||
cal.isDate(previousData.anchorDate, inSameDayAs: data.anchorDate),
|
||||
cal.isDate(previous.date, inSameDayAs: date) {
|
||||
continue
|
||||
}
|
||||
result.append(TimetableEntry(
|
||||
date: date,
|
||||
variant: variant,
|
||||
data: data,
|
||||
isLoggedIn: true,
|
||||
themeMode: theme
|
||||
))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
|
||||
@@ -10,6 +10,15 @@ enum WidgetLessonStatus: String, Codable {
|
||||
case irregular
|
||||
case teacherChanged
|
||||
case event
|
||||
case duty
|
||||
|
||||
/// Unknown future statuses degrade to `.regular` instead of failing the
|
||||
/// whole payload decode (mirrors WidgetData.kt's fromWire fallback) — a
|
||||
/// single new enum value must never blank the widget to the placeholder.
|
||||
init(from decoder: Decoder) throws {
|
||||
let raw = try decoder.singleValueContainer().decode(String.self)
|
||||
self = WidgetLessonStatus(rawValue: raw) ?? .regular
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetLesson: Codable {
|
||||
@@ -18,6 +27,8 @@ struct WidgetLesson: Codable {
|
||||
let subjectShort: String
|
||||
let subjectLong: String?
|
||||
let room: String?
|
||||
// On teacher accounts this carries the class label ("7a") instead of the
|
||||
// teacher short name (originalTeacher is nil then) — mapped in Dart.
|
||||
let teacher: String?
|
||||
let originalTeacher: String?
|
||||
let status: WidgetLessonStatus
|
||||
@@ -33,6 +44,12 @@ struct WidgetPeriod: Codable {
|
||||
let virtualEndMinutes: Int
|
||||
}
|
||||
|
||||
struct WidgetDayInfo: Codable {
|
||||
let date: Date
|
||||
let isHoliday: Bool
|
||||
let holidayName: String?
|
||||
}
|
||||
|
||||
struct WidgetTimetableData: Codable {
|
||||
let fetchedAt: Date
|
||||
let anchorDate: Date
|
||||
@@ -40,12 +57,17 @@ struct WidgetTimetableData: Codable {
|
||||
let periods: [WidgetPeriod]
|
||||
let isHoliday: Bool
|
||||
let holidayName: String?
|
||||
/// Week payload (v2) only; optional so day payloads keep decoding.
|
||||
let days: [WidgetDayInfo]?
|
||||
}
|
||||
|
||||
/// Mirrors lib/widget_data/widget_sync.dart (the canonical key list) — a
|
||||
/// schema bump must land in Dart, Kotlin (WidgetRenderer.kt) and here
|
||||
/// together, or the out-of-sync platform silently blanks to the placeholder.
|
||||
enum WidgetDataKey {
|
||||
static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
|
||||
static let dayData = "widget_data_day_v1"
|
||||
static let weekData = "widget_data_week_v1"
|
||||
static let weekData = "widget_data_week_v2"
|
||||
static let loggedIn = "widget_data_logged_in_v1"
|
||||
static let themeMode = "widget_setting_theme_mode_v1"
|
||||
}
|
||||
|
||||
@@ -2,13 +2,19 @@ import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
|
||||
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
|
||||
|
||||
/// Demo fixtures for the mobile capability flags — everything granted so the
|
||||
/// demo persona sees every feature (incl. push) as available.
|
||||
/// demo persona sees every feature (incl. push) as available and the timetable
|
||||
/// scroll range stays unlimited (null day counts).
|
||||
class DemoCapabilities {
|
||||
const DemoCapabilities._();
|
||||
|
||||
static CapabilitiesState state() => const CapabilitiesState(
|
||||
viewForeignTimetables: true,
|
||||
pushNotifications: true,
|
||||
timetablePastDays: null,
|
||||
timetableFutureDays: null,
|
||||
// Die Demo-Persona ist explizit Schüler — null hieße "Backend kennt das
|
||||
// Feld nicht" (siehe CapabilitiesResponse.userType).
|
||||
userType: 'STUDENT',
|
||||
loaded: true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
||||
@@ -18,21 +19,29 @@ class GetAppPassword {
|
||||
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
/// Returns the freshly minted app password. Throws on any transport or
|
||||
/// protocol error — callers treat push registration as best-effort and swallow
|
||||
/// failures.
|
||||
/// protocol error — a 401 becomes an [AuthException], which the login flow
|
||||
/// reads as "Nextcloud rejects the password" (two-factor authentication or
|
||||
/// password mismatch) and answers with the interactive Login Flow v2.
|
||||
Future<String> run() async {
|
||||
final response = await _client.get(
|
||||
NextcloudOcs.uri('core/getapppassword'),
|
||||
headers: {
|
||||
...NextcloudOcs.headers(),
|
||||
// Deliberately NOT the shared Authorization value: that one prefers
|
||||
// the app password, but an app password cannot mint another one —
|
||||
// this endpoint requires the real password.
|
||||
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
|
||||
},
|
||||
);
|
||||
const label = 'Nextcloud getapppassword';
|
||||
final response = (await sendGuarded(
|
||||
label,
|
||||
() => _client.get(
|
||||
NextcloudOcs.uri('core/getapppassword'),
|
||||
headers: {
|
||||
...NextcloudOcs.headers(),
|
||||
// Deliberately NOT the shared Authorization value: that one prefers
|
||||
// the app password, but an app password cannot mint another one —
|
||||
// this endpoint requires the real password.
|
||||
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
|
||||
},
|
||||
),
|
||||
))!;
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('getapppassword HTTP ${response.statusCode}');
|
||||
throwForStatus(
|
||||
response.statusCode,
|
||||
httpErrorDetail(label, response.body, response.statusCode),
|
||||
);
|
||||
}
|
||||
final json = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
final data = (json as Map)['ocs']?['data'];
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../../marianumconnect/auth/device_token_name.dart';
|
||||
|
||||
/// Nextcloud Login Flow v2 (`/index.php/login/v2`): interactive browser login
|
||||
/// that yields an app password. It is the only way to obtain working Nextcloud
|
||||
/// credentials when the account is protected by two-factor authentication —
|
||||
/// Basic auth with the real password is rejected server-side in that case.
|
||||
class LoginFlowApi {
|
||||
final http.Client _client;
|
||||
|
||||
LoginFlowApi({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
/// Starts a new flow. Nextcloud displays the request's User-Agent as the
|
||||
/// token name in the user's security settings, so the device token label is
|
||||
/// sent (`"Marianum Fulda App (Pixel 10)"`).
|
||||
Future<LoginFlowInit> start() async {
|
||||
final userAgent = await DeviceTokenName.resolve();
|
||||
final uri = _initUri();
|
||||
const label = 'Nextcloud login flow init';
|
||||
final response = (await sendGuarded(
|
||||
label,
|
||||
() => _client.post(
|
||||
uri,
|
||||
headers: {'Accept': 'application/json', 'User-Agent': userAgent},
|
||||
),
|
||||
))!;
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throwForStatus(
|
||||
response.statusCode,
|
||||
httpErrorDetail(label, response.body, response.statusCode),
|
||||
);
|
||||
}
|
||||
return LoginFlowInit.fromJson(
|
||||
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// Polls for the flow result: `null` while the browser login has not been
|
||||
/// completed yet (HTTP 404), the final credentials once it has.
|
||||
Future<LoginFlowCredentials?> poll(LoginFlowInit flow) async {
|
||||
const label = 'Nextcloud login flow poll';
|
||||
final response = (await sendGuarded(
|
||||
label,
|
||||
() => _client.post(
|
||||
Uri.parse(flow.pollEndpoint),
|
||||
headers: {'Accept': 'application/json'},
|
||||
body: {'token': flow.pollToken},
|
||||
),
|
||||
))!;
|
||||
if (response.statusCode == 404) return null;
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throwForStatus(
|
||||
response.statusCode,
|
||||
httpErrorDetail(label, response.body, response.statusCode),
|
||||
);
|
||||
}
|
||||
return LoginFlowCredentials.fromJson(
|
||||
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
static Uri _initUri() {
|
||||
final endpoint = EndpointData().nextcloud();
|
||||
return Uri.https(endpoint.domain, '${endpoint.path}/index.php/login/v2');
|
||||
}
|
||||
|
||||
/// Whether the login name reported by the completed flow belongs to the
|
||||
/// account this app session expects — the browser login could have been
|
||||
/// completed with a different Nextcloud account.
|
||||
static bool loginNameMatches({
|
||||
required String expected,
|
||||
required String actual,
|
||||
}) => actual.trim().toLowerCase() == expected.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/// Response of the flow init call: the URL the user opens in the browser plus
|
||||
/// the token/endpoint pair the app polls until the login is confirmed.
|
||||
class LoginFlowInit {
|
||||
final String loginUrl;
|
||||
final String pollToken;
|
||||
final String pollEndpoint;
|
||||
|
||||
const LoginFlowInit({
|
||||
required this.loginUrl,
|
||||
required this.pollToken,
|
||||
required this.pollEndpoint,
|
||||
});
|
||||
|
||||
factory LoginFlowInit.fromJson(Map<String, dynamic> json) {
|
||||
final poll = json['poll'];
|
||||
final loginUrl = json['login'] as String?;
|
||||
final token = poll is Map ? poll['token'] as String? : null;
|
||||
final endpoint = poll is Map ? poll['endpoint'] as String? : null;
|
||||
if (loginUrl == null || loginUrl.isEmpty) {
|
||||
throw const FormatException('login flow init: missing login url');
|
||||
}
|
||||
if (token == null || token.isEmpty || endpoint == null || endpoint.isEmpty) {
|
||||
throw const FormatException('login flow init: missing poll token/endpoint');
|
||||
}
|
||||
return LoginFlowInit(
|
||||
loginUrl: loginUrl,
|
||||
pollToken: token,
|
||||
pollEndpoint: endpoint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Credentials returned once the user confirmed the login in the browser.
|
||||
class LoginFlowCredentials {
|
||||
final String server;
|
||||
final String loginName;
|
||||
final String appPassword;
|
||||
|
||||
const LoginFlowCredentials({
|
||||
required this.server,
|
||||
required this.loginName,
|
||||
required this.appPassword,
|
||||
});
|
||||
|
||||
factory LoginFlowCredentials.fromJson(Map<String, dynamic> json) {
|
||||
final loginName = json['loginName'] as String?;
|
||||
final appPassword = json['appPassword'] as String?;
|
||||
if (loginName == null || loginName.isEmpty) {
|
||||
throw const FormatException('login flow poll: missing loginName');
|
||||
}
|
||||
if (appPassword == null || appPassword.isEmpty) {
|
||||
throw const FormatException('login flow poll: missing appPassword');
|
||||
}
|
||||
return LoginFlowCredentials(
|
||||
server: json['server'] as String? ?? '',
|
||||
loginName: loginName,
|
||||
appPassword: appPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,31 @@ import '../../api_response.dart';
|
||||
abstract class WebdavApi<T> {
|
||||
T genericParams;
|
||||
|
||||
WebdavApi(this.genericParams) {
|
||||
establishWebdavConnection();
|
||||
}
|
||||
WebdavApi(this.genericParams);
|
||||
|
||||
Future<ApiResponse> run();
|
||||
|
||||
static Future<WebDavClient> webdav = establishWebdavConnection();
|
||||
static Future<WebDavClient>? _webdav;
|
||||
static String? _webdavSecret;
|
||||
|
||||
/// Shared WebDAV client. Rebuilt whenever the effective Nextcloud secret
|
||||
/// changes (app password minted/renewed, account switch) so it never keeps
|
||||
/// authenticating with stale credentials.
|
||||
static Future<WebDavClient> get webdav {
|
||||
final secret = AccountData().getNextcloudSecret();
|
||||
if (_webdav == null || _webdavSecret != secret) {
|
||||
_webdavSecret = secret;
|
||||
_webdav = establishWebdavConnection();
|
||||
}
|
||||
return _webdav!;
|
||||
}
|
||||
|
||||
static Future<WebDavClient> establishWebdavConnection() async =>
|
||||
NextcloudClient(
|
||||
Uri.parse('https://${EndpointData().nextcloud().full()}'),
|
||||
password: AccountData().getPassword(),
|
||||
// App password preferred — with 2FA the real password is not accepted
|
||||
// by Nextcloud at all (see AccountData.usesLoginFlow).
|
||||
password: AccountData().getNextcloudSecret(),
|
||||
loginName: AccountData().getUsername(),
|
||||
).webdav;
|
||||
|
||||
|
||||
@@ -16,9 +16,23 @@ class CapabilitiesResponse {
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool pushNotifications;
|
||||
|
||||
/// How many days into the past/future the user may view the timetable.
|
||||
/// `null` (absent) means unlimited — the school year alone governs. The
|
||||
/// backend widens both to at least cover the current Mon–Sun week.
|
||||
final int? timetablePastDays;
|
||||
|
||||
final int? timetableFutureDays;
|
||||
|
||||
/// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend
|
||||
/// predates the field or has no LDAP record for the user.
|
||||
final String? userType;
|
||||
|
||||
CapabilitiesResponse({
|
||||
required this.viewForeignTimetables,
|
||||
required this.pushNotifications,
|
||||
this.timetablePastDays,
|
||||
this.timetableFutureDays,
|
||||
this.userType,
|
||||
});
|
||||
|
||||
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -11,6 +11,9 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
|
||||
) => CapabilitiesResponse(
|
||||
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
|
||||
pushNotifications: json['pushNotifications'] as bool? ?? false,
|
||||
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
|
||||
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
|
||||
userType: json['userType'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CapabilitiesResponseToJson(
|
||||
@@ -18,4 +21,7 @@ Map<String, dynamic> _$CapabilitiesResponseToJson(
|
||||
) => <String, dynamic>{
|
||||
'viewForeignTimetables': instance.viewForeignTimetables,
|
||||
'pushNotifications': instance.pushNotifications,
|
||||
'timetablePastDays': instance.timetablePastDays,
|
||||
'timetableFutureDays': instance.timetableFutureDays,
|
||||
'userType': instance.userType,
|
||||
};
|
||||
|
||||
+13
-2
@@ -18,6 +18,7 @@ import 'routing/app_routes.dart';
|
||||
import 'share_intent/share_intent_listener.dart';
|
||||
import 'state/app/modules/app_modules.dart';
|
||||
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
||||
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
@@ -62,7 +63,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
_syncChatListPolling();
|
||||
}
|
||||
|
||||
void _syncChatListPolling() {
|
||||
void _syncChatListPolling({bool refresh = true}) {
|
||||
if (!mounted) return;
|
||||
final modules = AppModule.getBottomBarModules(context);
|
||||
final talkSlot = modules.indexWhere((m) => m.module == Modules.talk);
|
||||
@@ -72,7 +73,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
bloc.setAutoRefreshInterval(
|
||||
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
|
||||
);
|
||||
if (talkIsActive) bloc.refresh();
|
||||
if (talkIsActive && refresh) bloc.refresh();
|
||||
}
|
||||
|
||||
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
|
||||
@@ -103,7 +104,14 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
log('Refreshing due to LifecycleChange');
|
||||
NotificationTasks.updateProviders(context);
|
||||
});
|
||||
// updateProviders already refreshes the chat list; only re-arm the poll.
|
||||
_syncChatListPolling(refresh: false);
|
||||
_handlePendingWidgetNavigation();
|
||||
} else if (mounted) {
|
||||
// Stop polling while backgrounded: a silent refresh failing in the
|
||||
// background would otherwise leave an error that flashes on the next
|
||||
// resume before the foreground refetch replaces it.
|
||||
context.read<ChatListBloc>().setAutoRefreshInterval(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +174,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
// Mirror BLoC updates into the home-screen widget without waiting
|
||||
// for the periodic background refresh.
|
||||
final settingsCubit = context.read<SettingsCubit>();
|
||||
final capabilitiesCubit = context.read<CapabilitiesCubit>();
|
||||
_timetableWidgetSync?.cancel();
|
||||
_timetableWidgetSync = timetable.stream.listen((state) {
|
||||
final data = state.data;
|
||||
@@ -174,6 +183,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
WidgetPublisher.publishFromBlocState(
|
||||
data,
|
||||
settings: settingsCubit.val(),
|
||||
isTeacher: capabilitiesCubit.isTeacher,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -185,6 +195,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
WidgetPublisher.publishFromBlocState(
|
||||
initialData,
|
||||
settings: settingsCubit.val(),
|
||||
isTeacher: capabilitiesCubit.isTeacher,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
@@ -32,12 +33,20 @@ class WidgetBackgroundTask {
|
||||
|
||||
static const Duration periodicFrequency = Duration(minutes: 30);
|
||||
|
||||
/// A snapshot younger than this is considered fresh enough — a second
|
||||
/// trigger within the window (push + periodic slot coinciding) is skipped.
|
||||
static const Duration refreshDebounce = Duration(minutes: 10);
|
||||
|
||||
static Future<void> initialize() async {
|
||||
await Workmanager().initialize(_callbackDispatcher);
|
||||
await Workmanager().registerPeriodicTask(
|
||||
periodicTaskName,
|
||||
periodicTaskName,
|
||||
frequency: periodicFrequency,
|
||||
// iOS ignores `frequency:` and instead uses initialDelay as the
|
||||
// BGAppRefresh earliestBeginDate on every (auto-)resubmission —
|
||||
// without it each completed run is immediately eligible again.
|
||||
initialDelay: Platform.isIOS ? periodicFrequency : Duration.zero,
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
||||
backoffPolicy: BackoffPolicy.linear,
|
||||
@@ -45,7 +54,21 @@ class WidgetBackgroundTask {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<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(
|
||||
'$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}',
|
||||
oneOffTaskName,
|
||||
@@ -54,24 +77,61 @@ class WidgetBackgroundTask {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared refresh entry for the periodic worker, push triggers, and login.
|
||||
/// Throws on fetch failure so the worker path can signal a retry.
|
||||
static Future<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 {
|
||||
await Workmanager().cancelAll();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure debounce decision so it stays unit-testable. A `fetchedAt` in the
|
||||
/// future (clock change, debug time shift) never skips — refreshing is the
|
||||
/// safe direction.
|
||||
bool shouldSkipRefresh({
|
||||
required DateTime? fetchedAt,
|
||||
required DateTime now,
|
||||
required bool force,
|
||||
}) {
|
||||
if (force || fetchedAt == null) return false;
|
||||
final age = now.difference(fetchedAt);
|
||||
return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce;
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void _callbackDispatcher() {
|
||||
Workmanager().executeTask((task, inputData) async {
|
||||
try {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await AccountData().waitForPopulation();
|
||||
if (!AccountData().isPopulated()) {
|
||||
log('[widget-bg] not logged in, skipping refresh');
|
||||
await WidgetSync.setLoggedIn(false);
|
||||
await WidgetSync.triggerUpdate();
|
||||
return true;
|
||||
}
|
||||
await _refresh();
|
||||
await WidgetBackgroundTask.runRefreshNow();
|
||||
return true;
|
||||
} on Exception catch (e, s) {
|
||||
log('[widget-bg] refresh failed: $e', stackTrace: s);
|
||||
@@ -94,39 +154,51 @@ Future<void> _refresh() async {
|
||||
}
|
||||
|
||||
final now = WidgetPublisher.widgetNow();
|
||||
// 14-day window so the week-widget rolls forward into next Monday's
|
||||
// lessons on Friday evening.
|
||||
final weekStart = _startOfWeek(now);
|
||||
final weekEndExclusive = weekStart.add(const Duration(days: 14));
|
||||
// Fetch window matches the week payload's window so the widget can roll
|
||||
// forward into next week's lessons without fresh data.
|
||||
final weekStart = WidgetDataMapper.startOfCalendarWeek(now);
|
||||
final weekEndExclusive = weekStart.add(
|
||||
const Duration(days: WidgetDataMapper.weekWindowDays),
|
||||
);
|
||||
|
||||
final timetable = await TimetableGetWeek().run(
|
||||
// All six requests are independent — run them concurrently so the total
|
||||
// latency is the slowest request, not the sum (matters for the push path's
|
||||
// hard time budget). Reference-data failures fall through to null in the
|
||||
// mapper rather than aborting the whole refresh.
|
||||
final timetableFuture = TimetableGetWeek().run(
|
||||
from: weekStart,
|
||||
until: weekEndExclusive.subtract(const Duration(days: 1)),
|
||||
);
|
||||
|
||||
// Reference data — failures fall through to null in the mapper rather
|
||||
// than aborting the whole refresh.
|
||||
final subjects = await _runOrNull<TimetableGetSubjectsResponse>(
|
||||
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
|
||||
() => TimetableGetSubjects().run(),
|
||||
);
|
||||
final rooms = await _runOrNull<TimetableGetRoomsResponse>(
|
||||
final roomsFuture = _runOrNull<TimetableGetRoomsResponse>(
|
||||
() => TimetableGetRooms().run(),
|
||||
);
|
||||
final holidays = await _runOrNull<TimetableGetHolidaysResponse>(
|
||||
final holidaysFuture = _runOrNull<TimetableGetHolidaysResponse>(
|
||||
() => TimetableGetHolidays().run(),
|
||||
);
|
||||
final timegrid = await _runOrNull<TimetableGetTimegridResponse>(
|
||||
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
|
||||
() => TimetableGetTimegrid().run(),
|
||||
);
|
||||
final customEvents = await _runOrNull<GetCustomTimetableEventResponse>(
|
||||
final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>(
|
||||
() => GetCustomTimetableEvent(
|
||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
||||
).run(),
|
||||
);
|
||||
final timetable = await timetableFuture;
|
||||
final subjects = await subjectsFuture;
|
||||
final rooms = await roomsFuture;
|
||||
final holidays = await holidaysFuture;
|
||||
final timegrid = await timegridFuture;
|
||||
final customEvents = await customEventsFuture;
|
||||
|
||||
final lessons = timetable.entries;
|
||||
|
||||
final connectDouble = await WidgetSync.getConnectDoubleLessons();
|
||||
final [connectDouble, isTeacher] = await Future.wait([
|
||||
WidgetSync.getConnectDoubleLessons(),
|
||||
WidgetSync.getIsTeacher(),
|
||||
]);
|
||||
final dayData = WidgetDataMapper.buildDayData(
|
||||
now: now,
|
||||
lessons: lessons,
|
||||
@@ -136,6 +208,7 @@ Future<void> _refresh() async {
|
||||
timegrid: timegrid,
|
||||
customEvents: customEvents,
|
||||
connectDoubleLessons: connectDouble,
|
||||
showClassInsteadOfTeacher: isTeacher,
|
||||
);
|
||||
final weekData = WidgetDataMapper.buildWeekData(
|
||||
now: now,
|
||||
@@ -146,6 +219,7 @@ Future<void> _refresh() async {
|
||||
timegrid: timegrid,
|
||||
customEvents: customEvents,
|
||||
connectDoubleLessons: connectDouble,
|
||||
showClassInsteadOfTeacher: isTeacher,
|
||||
);
|
||||
|
||||
await WidgetSync.writeDayData(dayData);
|
||||
@@ -158,11 +232,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 {
|
||||
try {
|
||||
return await task();
|
||||
|
||||
@@ -17,6 +17,10 @@ class AccountData {
|
||||
// token, so two registrations need two app passwords.
|
||||
static const _appPasswordField = 'nextcloud_app_password';
|
||||
static const _appPasswordTalkField = 'nextcloud_app_password_talk';
|
||||
// Marks accounts whose Nextcloud credentials came from Login Flow v2 (2FA):
|
||||
// the real password is not valid against Nextcloud, only the flow-issued
|
||||
// app password is — and no further app passwords can be minted silently.
|
||||
static const _loginFlowField = 'nextcloud_login_flow';
|
||||
// Persists the demo session across cold starts (see DemoMode).
|
||||
static const _demoField = 'is_demo';
|
||||
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
|
||||
@@ -38,10 +42,17 @@ class AccountData {
|
||||
String? _appPassword;
|
||||
String? _appPasswordTalk;
|
||||
bool _isDemo = false;
|
||||
bool _usesLoginFlow = false;
|
||||
|
||||
/// True while the active session is a local demo session (see DemoMode).
|
||||
bool get isDemo => _isDemo;
|
||||
|
||||
/// True when the Nextcloud credentials were obtained via Login Flow v2
|
||||
/// (browser login, e.g. because the account has two-factor authentication).
|
||||
/// In that mode the stored real password only authenticates MarianumConnect;
|
||||
/// every Nextcloud call must use the flow-issued app password.
|
||||
bool get usesLoginFlow => _usesLoginFlow;
|
||||
|
||||
String getUsername() {
|
||||
if (_username == null) throw Exception('Username not initialized');
|
||||
return _username!;
|
||||
@@ -86,9 +97,11 @@ class AccountData {
|
||||
_appPassword = null;
|
||||
_appPasswordTalk = null;
|
||||
_isDemo = false;
|
||||
_usesLoginFlow = false;
|
||||
await _secureStorage.delete(key: _usernameField);
|
||||
await _secureStorage.delete(key: _passwordField);
|
||||
await _secureStorage.delete(key: _demoField);
|
||||
await _secureStorage.delete(key: _loginFlowField);
|
||||
await _clearAppPasswordStorage();
|
||||
await _clearAppPasswordTalkStorage();
|
||||
}
|
||||
@@ -111,6 +124,17 @@ class AccountData {
|
||||
await _clearAppPasswordStorage();
|
||||
}
|
||||
|
||||
/// Adopts an app password obtained via Login Flow v2 and switches the
|
||||
/// account into flow mode (see [usesLoginFlow]). Any previously stored Talk
|
||||
/// app password belonged to the old session era and is dropped — the second
|
||||
/// (optional) flow pass stores a fresh one via [setAppPasswordTalk].
|
||||
Future<void> setLoginFlow(String appPassword) async {
|
||||
await setAppPassword(appPassword);
|
||||
await clearAppPasswordTalk();
|
||||
_usesLoginFlow = true;
|
||||
await _secureStorage.write(key: _loginFlowField, value: 'true');
|
||||
}
|
||||
|
||||
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
|
||||
|
||||
/// Persists the app password backing the Talk push registration.
|
||||
@@ -156,6 +180,7 @@ class AccountData {
|
||||
_username = await _secureStorage.read(key: _usernameField);
|
||||
_password = await _secureStorage.read(key: _passwordField);
|
||||
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
|
||||
_usesLoginFlow = (await _secureStorage.read(key: _loginFlowField)) == 'true';
|
||||
try {
|
||||
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
|
||||
_appPasswordTalk = await pushSecureStorage.read(
|
||||
@@ -209,6 +234,10 @@ class AccountData {
|
||||
String getTalkBasicAuthHeader() {
|
||||
_requirePopulated();
|
||||
if (!hasAppPasswordTalk()) {
|
||||
// Login-flow account whose second (talk) flow pass was skipped: no
|
||||
// silent minting possible, the talk registration shares the single
|
||||
// flow-issued credential.
|
||||
if (_usesLoginFlow && hasAppPassword()) return _basicAuth(_appPassword!);
|
||||
throw StateError('Talk app password not available yet');
|
||||
}
|
||||
return _basicAuth(_appPasswordTalk!);
|
||||
@@ -222,6 +251,15 @@ class AccountData {
|
||||
return _basicAuth(_password!);
|
||||
}
|
||||
|
||||
/// Secret authenticating against Nextcloud: the app password once available
|
||||
/// (minted or flow-issued), otherwise the real password. Mirrors the
|
||||
/// preference of [getBasicAuthHeader] for clients that need the raw secret
|
||||
/// (WebDAV client construction).
|
||||
String getNextcloudSecret() {
|
||||
_requirePopulated();
|
||||
return _appPassword ?? _password!;
|
||||
}
|
||||
|
||||
void _requirePopulated() {
|
||||
if (!isPopulated()) {
|
||||
throw Exception(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:developer';
|
||||
import 'package:crypton/crypton.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
|
||||
import '../background/widget_background_task.dart';
|
||||
import '../notification/notification_service.dart';
|
||||
import 'chat_thread_store.dart';
|
||||
import 'nid_store.dart';
|
||||
@@ -12,6 +13,10 @@ import 'push_registration_store.dart';
|
||||
import 'push_renderer.dart';
|
||||
import 'push_subject.dart';
|
||||
|
||||
/// Wire value of the FCM `type` field for silent widget-refresh pushes.
|
||||
/// Mirrors PUSH_TYPE_WIDGET_REFRESH in MarianumConnect's MarMobileApiService.
|
||||
const String widgetRefreshPushType = 'widget-refresh';
|
||||
|
||||
/// How an incoming FCM payload should be interpreted.
|
||||
enum PushKind {
|
||||
/// Encrypted Nextcloud push-v2 notification (`subject` + `signature`).
|
||||
@@ -20,6 +25,11 @@ enum PushKind {
|
||||
/// Plaintext MarianumConnect direct push (`source == "connect"`).
|
||||
connect,
|
||||
|
||||
/// Silent MarianumConnect push requesting a home-widget data refresh
|
||||
/// (`source == "connect"` + `type == "widget-refresh"`). Never rendered,
|
||||
/// processed even with notifications off.
|
||||
widgetRefresh,
|
||||
|
||||
/// Neither — ignored.
|
||||
unknown,
|
||||
}
|
||||
@@ -31,7 +41,10 @@ PushKind classifyPush(Map<String, dynamic> data) {
|
||||
final hasSubject = (data['subject'] as String?)?.isNotEmpty ?? false;
|
||||
final hasSignature = (data['signature'] as String?)?.isNotEmpty ?? false;
|
||||
if (hasSubject && hasSignature) return PushKind.nextcloud;
|
||||
if (data['source'] == 'connect') return PushKind.connect;
|
||||
if (data['source'] == 'connect') {
|
||||
if (data['type'] == widgetRefreshPushType) return PushKind.widgetRefresh;
|
||||
return PushKind.connect;
|
||||
}
|
||||
return PushKind.unknown;
|
||||
}
|
||||
|
||||
@@ -97,11 +110,30 @@ class PushMessageHandler {
|
||||
notificationsEnabled: notificationsEnabled,
|
||||
);
|
||||
break;
|
||||
case PushKind.widgetRefresh:
|
||||
// Deliberately before any notificationsEnabled gate: silent sync
|
||||
// pushes must work with notifications off.
|
||||
await _handleWidgetRefresh();
|
||||
break;
|
||||
case PushKind.unknown:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<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(
|
||||
RemoteMessage message, {
|
||||
required bool foreground,
|
||||
|
||||
@@ -67,6 +67,13 @@ class PushRegistration {
|
||||
/// registration binds to it, so it must be obtained before registering.
|
||||
Future<void> ensureAppPassword() async {
|
||||
if (AccountData().hasAppPassword()) return;
|
||||
if (AccountData().usesLoginFlow) {
|
||||
// Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt,
|
||||
// stilles Minting ist unmöglich. Reparatur nur interaktiv über
|
||||
// Einstellungen → „Nextcloud neu verbinden".
|
||||
log('Push: login-flow account without app password, cannot mint silently');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final appPassword = await GetAppPassword().run();
|
||||
await AccountData().setAppPassword(appPassword);
|
||||
@@ -79,6 +86,11 @@ class PushRegistration {
|
||||
/// (each `getapppassword` call with the real password mints a fresh one).
|
||||
Future<void> ensureTalkAppPassword() async {
|
||||
if (AccountData().hasAppPasswordTalk()) return;
|
||||
// Flow-Konten können still kein zweites App-Passwort münzen — das
|
||||
// Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin
|
||||
// teilt sich die Talk-Registrierung das eine App-Passwort (siehe
|
||||
// AccountData.getTalkBasicAuthHeader).
|
||||
if (AccountData().usesLoginFlow) return;
|
||||
try {
|
||||
final appPassword = await GetAppPassword().run();
|
||||
await AccountData().setAppPasswordTalk(appPassword);
|
||||
@@ -129,8 +141,19 @@ class PushRegistration {
|
||||
appVersion = null;
|
||||
}
|
||||
|
||||
final types = registrationTypesFor(
|
||||
usesLoginFlow: AccountData().usesLoginFlow,
|
||||
hasTalkAppPassword: AccountData().hasAppPasswordTalk(),
|
||||
);
|
||||
if (!types.contains(PushRegistrationType.general)) {
|
||||
await _recordAttempt(
|
||||
PushRegistrationType.general,
|
||||
'Ohne zweite Nextcloud-Freigabe nicht verfügbar (nur Talk-Push)',
|
||||
);
|
||||
}
|
||||
|
||||
var allOk = true;
|
||||
for (final type in PushRegistrationType.values) {
|
||||
for (final type in types) {
|
||||
final ok = await _registerType(
|
||||
type: type,
|
||||
fcmToken: fcmToken,
|
||||
@@ -260,6 +283,19 @@ class PushRegistration {
|
||||
await _store.clear();
|
||||
}
|
||||
|
||||
/// Pure decision which Nextcloud registrations this session can maintain.
|
||||
/// Flow-Konten (2FA), die nur den ersten Login-Flow-Durchlauf abgeschlossen
|
||||
/// haben, besitzen eine einzige NC-Session — Nextcloud bindet pro Session
|
||||
/// genau eine Subscription, also bleibt nur die (wichtigere)
|
||||
/// Talk-Registrierung. Mit dem zweiten (Talk-)App-Passwort aus dem
|
||||
/// optionalen zweiten Durchlauf laufen wieder beide.
|
||||
static List<PushRegistrationType> registrationTypesFor({
|
||||
required bool usesLoginFlow,
|
||||
required bool hasTalkAppPassword,
|
||||
}) => usesLoginFlow && !hasTalkAppPassword
|
||||
? const [PushRegistrationType.talk]
|
||||
: PushRegistrationType.values;
|
||||
|
||||
/// Pure decision for whether a persisted registration endpoint no longer
|
||||
/// matches the currently active one. A missing/empty stored value never
|
||||
/// forces a re-registration — old installs (pre endpoint-tracking) heal via
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../state/app/modules/app_modules.dart';
|
||||
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
||||
import '../view/login/nextcloud_login_flow_page.dart';
|
||||
import '../view/pages/files/files.dart';
|
||||
import '../view/pages/files/sharing/sharee_picker_page.dart';
|
||||
import '../view/pages/foreign_timetable/element_picker_page.dart';
|
||||
@@ -114,6 +115,20 @@ class AppRoutes {
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs the Nextcloud Login Flow v2 (browser login, e.g. for accounts with
|
||||
/// two-factor authentication) and resolves to `true` once an app password
|
||||
/// was adopted. Used from the login flow and the settings "reconnect"
|
||||
/// action.
|
||||
static Future<bool> openNextcloudLoginFlow(BuildContext context) async {
|
||||
final result = await Navigator.of(context).push<bool>(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => const NextcloudLoginFlowPage(),
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
/// Opens the tappable, zoomable profile-picture viewer for [id].
|
||||
static void openLargeProfilePicture(BuildContext context, String id) {
|
||||
Navigator.of(context).push(
|
||||
|
||||
@@ -17,6 +17,14 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
|
||||
|
||||
bool get canReceivePushNotifications => state.pushNotifications;
|
||||
|
||||
int? get timetablePastDays => state.timetablePastDays;
|
||||
|
||||
int? get timetableFutureDays => state.timetableFutureDays;
|
||||
|
||||
/// Teacher accounts get the class shown on timetable tiles instead of their
|
||||
/// own name (see TimetableAppointmentFactory.showClassInsteadOfTeacher).
|
||||
bool get isTeacher => state.userType == 'TEACHER';
|
||||
|
||||
/// Refreshes capabilities from the server. On any failure (endpoint not yet
|
||||
/// live, network error, 4xx) the previously hydrated flags are kept but the
|
||||
/// state is marked `loaded` — a failed fetch never silently grants a
|
||||
@@ -32,6 +40,9 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
|
||||
CapabilitiesState(
|
||||
viewForeignTimetables: response.viewForeignTimetables,
|
||||
pushNotifications: response.pushNotifications,
|
||||
timetablePastDays: response.timetablePastDays,
|
||||
timetableFutureDays: response.timetableFutureDays,
|
||||
userType: response.userType,
|
||||
loaded: true,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,12 @@ abstract class CapabilitiesState with _$CapabilitiesState {
|
||||
const factory CapabilitiesState({
|
||||
@Default(false) bool viewForeignTimetables,
|
||||
@Default(false) bool pushNotifications,
|
||||
// Days into the past/future the timetable may be scrolled. Null = no
|
||||
// client-side clamp; the (server-narrowed) school year alone governs.
|
||||
int? timetablePastDays,
|
||||
int? timetableFutureDays,
|
||||
// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
|
||||
String? userType,
|
||||
// Whether a capability response (or a definitive failure) has been
|
||||
// observed at least once this session. Lets the UI distinguish "still
|
||||
// unknown" from "confirmed not allowed".
|
||||
|
||||
@@ -15,7 +15,10 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$CapabilitiesState {
|
||||
|
||||
bool get viewForeignTimetables; bool get pushNotifications;// Whether a capability response (or a definitive failure) has been
|
||||
bool get viewForeignTimetables; bool get pushNotifications;// Days into the past/future the timetable may be scrolled. Null = no
|
||||
// client-side clamp; the (server-narrowed) school year alone governs.
|
||||
int? get timetablePastDays; int? get timetableFutureDays;// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
|
||||
String? get userType;// Whether a capability response (or a definitive failure) has been
|
||||
// observed at least once this session. Lets the UI distinguish "still
|
||||
// unknown" from "confirmed not allowed".
|
||||
bool get loaded;
|
||||
@@ -31,16 +34,16 @@ $CapabilitiesStateCopyWith<CapabilitiesState> get copyWith => _$CapabilitiesStat
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded);
|
||||
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, loaded: $loaded)';
|
||||
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)';
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +54,7 @@ abstract mixin class $CapabilitiesStateCopyWith<$Res> {
|
||||
factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool viewForeignTimetables, bool pushNotifications, bool loaded
|
||||
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded
|
||||
});
|
||||
|
||||
|
||||
@@ -68,11 +71,14 @@ class _$CapabilitiesStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of CapabilitiesState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? loaded = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? loaded = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
|
||||
as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
|
||||
as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable
|
||||
as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable
|
||||
as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
@@ -158,10 +164,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _CapabilitiesState() when $default != null:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -179,10 +185,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, bool loaded) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _CapabilitiesState():
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -199,10 +205,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, bool loaded)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _CapabilitiesState() when $default != null:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -214,11 +220,17 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded
|
||||
@JsonSerializable()
|
||||
|
||||
class _CapabilitiesState implements CapabilitiesState {
|
||||
const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.loaded = false});
|
||||
const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, this.loaded = false});
|
||||
factory _CapabilitiesState.fromJson(Map<String, dynamic> json) => _$CapabilitiesStateFromJson(json);
|
||||
|
||||
@override@JsonKey() final bool viewForeignTimetables;
|
||||
@override@JsonKey() final bool pushNotifications;
|
||||
// Days into the past/future the timetable may be scrolled. Null = no
|
||||
// client-side clamp; the (server-narrowed) school year alone governs.
|
||||
@override final int? timetablePastDays;
|
||||
@override final int? timetableFutureDays;
|
||||
// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
|
||||
@override final String? userType;
|
||||
// Whether a capability response (or a definitive failure) has been
|
||||
// observed at least once this session. Lets the UI distinguish "still
|
||||
// unknown" from "confirmed not allowed".
|
||||
@@ -237,16 +249,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded);
|
||||
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, loaded: $loaded)';
|
||||
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)';
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +269,7 @@ abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesS
|
||||
factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
bool viewForeignTimetables, bool pushNotifications, bool loaded
|
||||
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded
|
||||
});
|
||||
|
||||
|
||||
@@ -274,11 +286,14 @@ class __$CapabilitiesStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of CapabilitiesState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? loaded = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? loaded = null,}) {
|
||||
return _then(_CapabilitiesState(
|
||||
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
|
||||
as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
|
||||
as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable
|
||||
as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable
|
||||
as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable
|
||||
as String?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ _CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) =>
|
||||
_CapabilitiesState(
|
||||
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
|
||||
pushNotifications: json['pushNotifications'] as bool? ?? false,
|
||||
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
|
||||
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
|
||||
userType: json['userType'] as String?,
|
||||
loaded: json['loaded'] as bool? ?? false,
|
||||
);
|
||||
|
||||
@@ -17,5 +20,8 @@ Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) =>
|
||||
<String, dynamic>{
|
||||
'viewForeignTimetables': instance.viewForeignTimetables,
|
||||
'pushNotifications': instance.pushNotifications,
|
||||
'timetablePastDays': instance.timetablePastDays,
|
||||
'timetableFutureDays': instance.timetableFutureDays,
|
||||
'userType': instance.userType,
|
||||
'loaded': instance.loaded,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -6,13 +5,29 @@ import 'package:flutter/foundation.dart';
|
||||
import '../../api/demo/demo_mode.dart';
|
||||
import '../../api/errors/auth_exception.dart';
|
||||
import '../../api/errors/error_mapper.dart';
|
||||
import '../../api/marianumcloud/app_password/get_app_password.dart';
|
||||
import '../../api/marianumconnect/auth/device_token_name.dart';
|
||||
import '../../api/marianumconnect/auth/token_storage.dart';
|
||||
import '../../api/marianumconnect/queries/auth_login/auth_login.dart';
|
||||
import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||
import '../../model/account_data.dart';
|
||||
import '../../push/push_registration.dart';
|
||||
import '../../widget_data/widget_sync.dart';
|
||||
|
||||
/// Outcome of a login attempt.
|
||||
enum LoginResult {
|
||||
/// Fully logged in — the view transitions to `loggedIn`.
|
||||
success,
|
||||
|
||||
/// Credentials rejected or a transport problem; the error is exposed via
|
||||
/// [LoginController.errorMessage].
|
||||
failure,
|
||||
|
||||
/// MarianumConnect accepted the credentials, but Nextcloud rejects them
|
||||
/// (two-factor authentication active or diverging password). The view must
|
||||
/// complete the Nextcloud Login Flow v2 in the browser before proceeding.
|
||||
nextcloudLoginRequired,
|
||||
}
|
||||
|
||||
/// Owns the login flow's transient state (loading, last error) so it can be
|
||||
/// driven from a thin Stateful view and unit-tested without a widget tree.
|
||||
class LoginController extends ChangeNotifier {
|
||||
@@ -24,10 +39,8 @@ class LoginController extends ChangeNotifier {
|
||||
String? get errorMessage => _errorMessage;
|
||||
String? get errorDetails => _errorDetails;
|
||||
|
||||
/// Returns `true` when the credential probe succeeded. The view should
|
||||
/// then transition the AccountBloc to `loggedIn`.
|
||||
Future<bool> submit(String username, String password) async {
|
||||
if (_loading) return false;
|
||||
Future<LoginResult> submit(String username, String password) async {
|
||||
if (_loading) return LoginResult.failure;
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
_errorDetails = null;
|
||||
@@ -45,7 +58,7 @@ class LoginController extends ChangeNotifier {
|
||||
await AccountData().setDemo(user);
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
return LoginResult.success;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -65,13 +78,13 @@ class LoginController extends ChangeNotifier {
|
||||
tokenName: await DeviceTokenName.resolve(),
|
||||
);
|
||||
await AccountData().setData(user, password);
|
||||
// Mint the Nextcloud app password now so it's ready for the push
|
||||
// registration and subsequent NC calls. Non-blocking: on failure push
|
||||
// stays off and retries on the next start.
|
||||
unawaited(PushRegistration().ensureAppPassword());
|
||||
// Mint the Nextcloud app password now — it doubles as the Nextcloud
|
||||
// credential probe: a rejection means 2FA is active (or the NC password
|
||||
// diverges) and the login must finish interactively in the browser.
|
||||
final ncReady = await _prepareNextcloudAppPassword();
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired;
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
await AccountData().removeData();
|
||||
@@ -83,7 +96,41 @@ class LoginController extends ChangeNotifier {
|
||||
_errorDetails = errorToTechnicalDetails(e);
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
return LoginResult.failure;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to mint the Nextcloud app password with the just-verified password.
|
||||
/// `false` = Nextcloud rejected the credentials → Login Flow v2 required.
|
||||
/// Transport/server problems stay non-blocking (like the previous
|
||||
/// fire-and-forget mint): the mint retries with the push registration.
|
||||
Future<bool> _prepareNextcloudAppPassword() async {
|
||||
try {
|
||||
final appPassword = await GetAppPassword().run();
|
||||
await AccountData().setAppPassword(appPassword);
|
||||
return true;
|
||||
} on AuthException {
|
||||
return false;
|
||||
} on Object catch (e) {
|
||||
log('Nextcloud app password mint failed (non-blocking): $e');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolls the half-finished login back after the user cancelled the
|
||||
/// Nextcloud browser login: revoke the fresh MarianumConnect token and wipe
|
||||
/// the stored credentials, then surface why the login did not complete.
|
||||
Future<void> abortNextcloudLogin() async {
|
||||
try {
|
||||
await AuthLogout().run();
|
||||
} on Object catch (e) {
|
||||
log('Login rollback: MC logout failed: $e');
|
||||
}
|
||||
await AccountData().removeData();
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
_errorMessage =
|
||||
'Die Anmeldung wurde abgebrochen — dein Konto erfordert die Bestätigung im Browser.';
|
||||
_errorDetails = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../api/errors/error_mapper.dart';
|
||||
import '../../api/marianumcloud/app_password/delete_app_password.dart';
|
||||
import '../../api/marianumcloud/login_flow/login_flow_api.dart';
|
||||
import '../../model/account_data.dart';
|
||||
import '../../routing/app_routes.dart';
|
||||
import '../../widget/app_progress_indicator.dart';
|
||||
|
||||
/// Die beiden Durchläufe des Login Flow v2: Der erste liefert das allgemeine
|
||||
/// App-Passwort (voller Browser-Login inkl. 2FA), der zweite das
|
||||
/// Talk-App-Passwort für die zweite Push-Subscription — der Browser hat dann
|
||||
/// bereits eine Session, es bleibt nur der „Zugriff gewähren"-Tipp.
|
||||
enum _FlowStep { primary, talk }
|
||||
|
||||
/// Runs the Nextcloud Login Flow v2: opens the browser login, polls until the
|
||||
/// user confirmed it there (2FA happens inside the browser) and adopts the
|
||||
/// returned app password via [AccountData.setLoginFlow]. A second, skippable
|
||||
/// pass mints the Talk app password so flow accounts keep BOTH push
|
||||
/// subscriptions (see PushRegistrationType). Pops `true` once the primary
|
||||
/// credential was adopted, `false`/`null` when the user backs out before that.
|
||||
class NextcloudLoginFlowPage extends StatefulWidget {
|
||||
const NextcloudLoginFlowPage({super.key});
|
||||
|
||||
@override
|
||||
State<NextcloudLoginFlowPage> createState() => _NextcloudLoginFlowPageState();
|
||||
}
|
||||
|
||||
class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
||||
with WidgetsBindingObserver {
|
||||
static const _pollInterval = Duration(seconds: 3);
|
||||
// Serverseitig verfällt der Flow-Token nach 20 Minuten — danach würde der
|
||||
// Poll für immer 404 liefern, also vorher mit klarer Meldung abbrechen.
|
||||
static const _flowTimeout = Duration(minutes: 15);
|
||||
|
||||
final LoginFlowApi _api = LoginFlowApi();
|
||||
_FlowStep _step = _FlowStep.primary;
|
||||
LoginFlowInit? _flow;
|
||||
Timer? _timer;
|
||||
DateTime? _startedAt;
|
||||
bool _polling = false;
|
||||
bool _finished = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
unawaited(_start());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Der Nutzer kommt gerade aus dem Browser zurück — sofort pollen statt
|
||||
// bis zu einem Intervall zu warten.
|
||||
if (state == AppLifecycleState.resumed) unawaited(_poll());
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
_timer?.cancel();
|
||||
setState(() {
|
||||
_error = null;
|
||||
_flow = null;
|
||||
});
|
||||
try {
|
||||
final flow = await _api.start();
|
||||
if (!mounted) return;
|
||||
setState(() => _flow = flow);
|
||||
_startedAt = DateTime.now();
|
||||
_timer = Timer.periodic(_pollInterval, (_) => _poll());
|
||||
unawaited(AppRoutes.openExternalUrl(flow.loginUrl));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = errorToUserMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
final flow = _flow;
|
||||
if (flow == null || _polling || _finished || _error != null) return;
|
||||
final startedAt = _startedAt;
|
||||
if (startedAt != null &&
|
||||
DateTime.now().difference(startedAt) > _flowTimeout) {
|
||||
_timer?.cancel();
|
||||
setState(
|
||||
() => _error =
|
||||
'Zeitüberschreitung — die Anmeldung im Browser wurde nicht abgeschlossen.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_polling = true;
|
||||
try {
|
||||
final credentials = await _api.poll(flow);
|
||||
if (credentials == null || _finished || !mounted) return;
|
||||
if (!LoginFlowApi.loginNameMatches(
|
||||
expected: AccountData().getUsername(),
|
||||
actual: credentials.loginName,
|
||||
)) {
|
||||
_timer?.cancel();
|
||||
// Das versehentlich für das fremde Konto ausgestellte App-Passwort
|
||||
// nicht liegen lassen.
|
||||
unawaited(_revokeForeignAppPassword(credentials));
|
||||
setState(
|
||||
() => _error =
|
||||
'Im Browser wurde ein anderes Konto angemeldet („${credentials.loginName}“). '
|
||||
'Bitte versuche es erneut mit deinem Konto.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
switch (_step) {
|
||||
case _FlowStep.primary:
|
||||
await AccountData().setLoginFlow(credentials.appPassword);
|
||||
if (!mounted) return;
|
||||
// Zweite Freigabe direkt anstoßen: die Browser-Session besteht
|
||||
// bereits, es fehlt nur noch der Grant-Tipp.
|
||||
setState(() => _step = _FlowStep.talk);
|
||||
unawaited(_start());
|
||||
case _FlowStep.talk:
|
||||
_finished = true;
|
||||
_timer?.cancel();
|
||||
await AccountData().setAppPasswordTalk(credentials.appPassword);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
} on Object catch (e) {
|
||||
// Transienter Poll-Fehler (z.B. kurz offline) — der nächste Tick
|
||||
// versucht es erneut.
|
||||
log('Login flow poll failed (retrying): $e');
|
||||
} finally {
|
||||
_polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Talk-Schritt ist optional: ohne zweites App-Passwort funktioniert
|
||||
/// alles außer den allgemeinen Nextcloud-Pushes (Talk-Push bleibt erhalten).
|
||||
void _skipTalkStep() {
|
||||
_finished = true;
|
||||
_timer?.cancel();
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
static Future<void> _revokeForeignAppPassword(
|
||||
LoginFlowCredentials credentials,
|
||||
) async {
|
||||
try {
|
||||
final basic = base64Encode(
|
||||
utf8.encode('${credentials.loginName}:${credentials.appPassword}'),
|
||||
);
|
||||
await DeleteAppPassword().run(authorizationHeader: 'Basic $basic');
|
||||
} on Object catch (e) {
|
||||
log('Login flow: could not revoke foreign app password: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final flow = _flow;
|
||||
final error = _error;
|
||||
final isTalkStep = _step == _FlowStep.talk;
|
||||
// Ab dem Talk-Schritt ist das primäre App-Passwort bereits übernommen —
|
||||
// Zurück heißt dann „überspringen" (pop true), nicht „Login abbrechen":
|
||||
// die Aufrufer würden bei false den kompletten Login zurückrollen.
|
||||
return PopScope(
|
||||
canPop: !isTalkStep,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop && !_finished) _skipTalkStep();
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('Nextcloud-Anmeldung')),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
isTalkStep
|
||||
? Icons.notifications_active_outlined
|
||||
: Icons.verified_user_outlined,
|
||||
size: 56,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isTalkStep
|
||||
? 'Fast geschafft!'
|
||||
: 'Bestätigung erforderlich',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isTalkStep
|
||||
? 'Damit Benachrichtigungen vollständig ankommen, braucht '
|
||||
'die App eine zweite Freigabe. Du bist im Browser '
|
||||
'bereits angemeldet — es genügt ein Tipp auf '
|
||||
'„Zugriff gewähren“.'
|
||||
: 'Dein Konto ist zusätzlich geschützt (z.B. durch '
|
||||
'Zwei-Faktor-Authentifizierung). Schließe die '
|
||||
'Anmeldung im Browser ab — danach geht es hier '
|
||||
'automatisch weiter.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (error != null) ...[
|
||||
Text(
|
||||
error,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _start,
|
||||
child: const Text('Erneut versuchen'),
|
||||
),
|
||||
] else if (flow == null) ...[
|
||||
const Center(child: AppProgressIndicator.medium()),
|
||||
] else ...[
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_browser),
|
||||
onPressed: () =>
|
||||
unawaited(AppRoutes.openExternalUrl(flow.loginUrl)),
|
||||
label: Text(
|
||||
isTalkStep
|
||||
? 'Freigabe im Browser bestätigen'
|
||||
: 'Anmeldung im Browser öffnen',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const AppProgressIndicator.small(),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Warte auf Bestätigung im Browser…',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (isTalkStep) ...[
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: _skipTalkStep,
|
||||
child: const Text('Überspringen'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../login_controller.dart';
|
||||
import 'login_error_banner.dart';
|
||||
|
||||
@@ -51,11 +52,27 @@ class _LoginCardState extends State<LoginCard> {
|
||||
Future<void> _submit() async {
|
||||
if (widget.controller.loading) return;
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
final ok = await widget.controller.submit(
|
||||
final result = await widget.controller.submit(
|
||||
_usernameController.text,
|
||||
_passwordController.text,
|
||||
);
|
||||
if (ok && mounted) widget.onSuccess();
|
||||
if (!mounted) return;
|
||||
switch (result) {
|
||||
case LoginResult.success:
|
||||
widget.onSuccess();
|
||||
case LoginResult.nextcloudLoginRequired:
|
||||
// 2FA (oder abweichendes NC-Passwort): Anmeldung im Browser über den
|
||||
// Login Flow v2 abschließen; ohne Erfolg wird der Login zurückgerollt.
|
||||
final ok = await AppRoutes.openNextcloudLoginFlow(context);
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
widget.onSuccess();
|
||||
} else {
|
||||
await widget.controller.abortNextcloudLogin();
|
||||
}
|
||||
case LoginResult.failure:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
InputDecoration _decoration(ThemeData theme, String label, IconData icon) =>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
@@ -167,10 +169,35 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Nur für Login-Flow-Konten (2FA) sichtbar — Passwort-Konten heilen
|
||||
// sich still über das App-Passwort-Minting und sollen von dem ganzen
|
||||
// Flow-Mechanismus nichts mitbekommen.
|
||||
if (!AccountData().isDemo && AccountData().usesLoginFlow)
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.cloud_sync_outlined),
|
||||
title: const Text('Nextcloud neu verbinden'),
|
||||
subtitle: const Text(
|
||||
'Bei Anmeldeproblemen in Talk oder Dateien',
|
||||
),
|
||||
closeOnSuccess: false,
|
||||
onPressed: _reconnectNextcloud,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Erneuert die Nextcloud-Zugangsdaten über den Login Flow v2 (inkl. des
|
||||
/// zweiten Talk-Durchlaufs) und bindet die Push-Subscription neu.
|
||||
Future<void> _reconnectNextcloud() async {
|
||||
final ok = await AppRoutes.openNextcloudLoginFlow(context);
|
||||
if (!ok || !mounted) return;
|
||||
// Neues App-Passwort = neue NC-Session: die Push-Subscription neu binden.
|
||||
unawaited(PushRegistration().register());
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showLogoutDialog(BuildContext context) async {
|
||||
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
||||
// inside removeData (the previous approach) raced AsyncDialogAction's
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
|
||||
final RegExp _whitespaceRun = RegExp(r'\s+');
|
||||
|
||||
/// Collapses any line-break or whitespace run to a single space and trims.
|
||||
/// Returns null when input is null or fully whitespace. Webuntis sometimes
|
||||
/// returns multi-line values like "A30\n4" — this normalizes those so labels
|
||||
/// render on a single line.
|
||||
String? collapseWhitespace(String? s) {
|
||||
if (s == null) return null;
|
||||
final cleaned = s.replaceAll(_whitespaceRun, ' ').trim();
|
||||
return cleaned.isEmpty ? null : cleaned;
|
||||
}
|
||||
|
||||
/// "7a, 7b" — shared by the calendar tile factory and the home-widget mapper
|
||||
/// so both surfaces render identical class labels on teacher plans.
|
||||
extension LessonClassLabel on McTimetableEntry {
|
||||
String? get classLabel => collapseWhitespace(classNames.join(', '));
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
|
||||
/// Combines back-to-back lessons with identical subject/room/teacher/status
|
||||
@@ -44,6 +46,9 @@ class LessonMerger {
|
||||
b.teachers.firstOrNull?.shortName) {
|
||||
return false;
|
||||
}
|
||||
// Relevant für Lehrerpläne: gleicher Lehrer/Fach/Raum, aber verschiedene
|
||||
// Klassen dürfen nicht zu einem Block verschmelzen.
|
||||
if (!listEquals(a.classNames, b.classNames)) return false;
|
||||
if (a.status != b.status) return false;
|
||||
// Lower bound on the gap — without it, two identical-metadata lessons that
|
||||
// overlap in time would silently collapse into one.
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'
|
||||
import '../../../../storage/timetable_settings.dart';
|
||||
import 'arbitrary_appointment.dart';
|
||||
import 'lesson_color.dart';
|
||||
import 'lesson_labels.dart';
|
||||
import 'lesson_merger.dart';
|
||||
import 'lesson_status.dart';
|
||||
import 'lesson_type_label.dart';
|
||||
@@ -23,6 +24,10 @@ class TimetableAppointmentFactory {
|
||||
final TimetableSettings settings;
|
||||
final DateTime now;
|
||||
|
||||
/// Teacher plans (a teacher's own plan or a foreign teacher view) show the
|
||||
/// class on the tile instead of the teacher's own name.
|
||||
final bool showClassInsteadOfTeacher;
|
||||
|
||||
TimetableAppointmentFactory({
|
||||
required this.lessons,
|
||||
required this.customEvents,
|
||||
@@ -30,6 +35,7 @@ class TimetableAppointmentFactory {
|
||||
required this.settings,
|
||||
required this.now,
|
||||
this.holidays = const [],
|
||||
this.showClassInsteadOfTeacher = false,
|
||||
});
|
||||
|
||||
List<Appointment> build() {
|
||||
@@ -130,7 +136,7 @@ class TimetableAppointmentFactory {
|
||||
location: event.description.trim().isEmpty
|
||||
? null
|
||||
: event.description.trim(),
|
||||
subject: _collapseWhitespace(event.title) ?? event.title,
|
||||
subject: collapseWhitespace(event.title) ?? event.title,
|
||||
recurrenceRule: parsed.rule,
|
||||
recurrenceExceptionDates: exceptionDates.isEmpty ? null : exceptionDates,
|
||||
color:
|
||||
@@ -222,7 +228,7 @@ class TimetableAppointmentFactory {
|
||||
TimetableNameMode.longName => lookup?.longName ?? subjectShort,
|
||||
TimetableNameMode.alternateName => lookup?.longName ?? subjectShort,
|
||||
};
|
||||
final collapsed = _collapseWhitespace(name);
|
||||
final collapsed = collapseWhitespace(name);
|
||||
if (collapsed != null) return collapsed;
|
||||
}
|
||||
// Subject leer → Titel aus dem Lesson-Type ableiten. Pausenaufsicht etc.
|
||||
@@ -233,10 +239,13 @@ class TimetableAppointmentFactory {
|
||||
|
||||
String _locationLabel(McTimetableEntry lesson) {
|
||||
final roomName =
|
||||
_collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt';
|
||||
final teacherName =
|
||||
_teacherLabel(lesson.teachers.firstOrNull) ?? 'Unbekannt';
|
||||
return '$roomName\n$teacherName';
|
||||
collapseWhitespace(lesson.rooms.firstOrNull) ?? 'Unbekannt';
|
||||
// Klassenlose Einträge (Aufsichten etc.) fallen auf den Lehrer zurück.
|
||||
final secondLine =
|
||||
(showClassInsteadOfTeacher ? lesson.classLabel : null) ??
|
||||
_teacherLabel(lesson.teachers.firstOrNull) ??
|
||||
'Unbekannt';
|
||||
return '$roomName\n$secondLine';
|
||||
}
|
||||
|
||||
/// Backend serves teachers with their full display name ("Stefan Müller"),
|
||||
@@ -245,27 +254,11 @@ class TimetableAppointmentFactory {
|
||||
/// overview; the detail sheet still renders the full name as a subtitle.
|
||||
static String? _teacherLabel(McTimetableTeacher? teacher) {
|
||||
if (teacher == null) return null;
|
||||
final display = _collapseWhitespace(teacher.displayName);
|
||||
final display = collapseWhitespace(teacher.displayName);
|
||||
if (display != null && display.isNotEmpty) {
|
||||
final parts = display.split(' ');
|
||||
return parts.isEmpty ? display : parts.last;
|
||||
}
|
||||
return _collapseWhitespace(teacher.shortName);
|
||||
}
|
||||
|
||||
/// Collapses any line-break or whitespace run to a single space and trims.
|
||||
/// Returns null when input is null or fully whitespace. Webuntis sometimes
|
||||
/// returns multi-line room names like "A30\n4" — this normalizes those so
|
||||
/// the tile renders the room on a single line.
|
||||
static String? _collapseWhitespace(String? s) {
|
||||
if (s == null) return null;
|
||||
final cleaned = s
|
||||
.replaceAll('\r\n', ' ')
|
||||
.replaceAll('\n', ' ')
|
||||
.replaceAll('\r', ' ')
|
||||
.replaceAll('\t', ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
return cleaned.isEmpty ? null : cleaned;
|
||||
return collapseWhitespace(teacher.shortName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,9 +101,8 @@ class _TimetableState extends State<Timetable> {
|
||||
final loadableState = context.watch<TimetableBloc>().state;
|
||||
final innerState = loadableState.data;
|
||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||
final canViewForeign = context
|
||||
.watch<CapabilitiesCubit>()
|
||||
.canViewForeignTimetables;
|
||||
final capabilities = context.watch<CapabilitiesCubit>();
|
||||
final canViewForeign = capabilities.canViewForeignTimetables;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen
|
||||
@@ -166,6 +165,7 @@ class _TimetableState extends State<Timetable> {
|
||||
),
|
||||
onCreateEvent: _onCreateEventAt,
|
||||
customEvents: state.customEvents?.events ?? const [],
|
||||
showClassInsteadOfTeacher: capabilities.isTeacher,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -217,6 +217,8 @@ class _TimetableState extends State<Timetable> {
|
||||
onAppointmentTap: (apt) =>
|
||||
AppointmentDetailsDispatcher.show(context, state, apt),
|
||||
customEvents: const [],
|
||||
showClassInsteadOfTeacher:
|
||||
selected.type == TimetableElementType.teacher,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../../storage/timetable_settings.dart';
|
||||
@@ -27,6 +28,10 @@ class TimetableCalendarView extends StatefulWidget {
|
||||
final void Function(DateTime start, DateTime end)? onCreateEvent;
|
||||
final List<CustomTimetableEvent> customEvents;
|
||||
|
||||
/// True for teacher plans — tiles then show the class instead of the
|
||||
/// teacher name (see [TimetableAppointmentFactory.showClassInsteadOfTeacher]).
|
||||
final bool showClassInsteadOfTeacher;
|
||||
|
||||
const TimetableCalendarView({
|
||||
super.key,
|
||||
required this.state,
|
||||
@@ -34,6 +39,7 @@ class TimetableCalendarView extends StatefulWidget {
|
||||
required this.onAppointmentTap,
|
||||
this.onCreateEvent,
|
||||
this.customEvents = const [],
|
||||
this.showClassInsteadOfTeacher = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -45,9 +51,9 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
GlobalKey<CustomWorkWeekCalendarState>();
|
||||
|
||||
List<Appointment>? _cachedAppointments;
|
||||
int? _lastDataVersion;
|
||||
TimetableSettings? _lastTimetableSettings;
|
||||
List<CustomTimetableEvent>? _lastCustomEvents;
|
||||
// TimetableSettings and List define no `==`, so record equality degrades to
|
||||
// the same identity checks the cache always used.
|
||||
(int, TimetableSettings, List<CustomTimetableEvent>, bool)? _cacheKey;
|
||||
|
||||
DateTime _initialDisplayDate() => DateTime.now().addDays(2);
|
||||
|
||||
@@ -62,15 +68,16 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.timetableSettings;
|
||||
if (_cachedAppointments != null &&
|
||||
_lastDataVersion == state.dataVersion &&
|
||||
identical(_lastTimetableSettings, timetableSettings) &&
|
||||
identical(_lastCustomEvents, widget.customEvents)) {
|
||||
final key = (
|
||||
state.dataVersion,
|
||||
timetableSettings,
|
||||
widget.customEvents,
|
||||
widget.showClassInsteadOfTeacher,
|
||||
);
|
||||
if (_cachedAppointments != null && _cacheKey == key) {
|
||||
return _cachedAppointments!;
|
||||
}
|
||||
_lastDataVersion = state.dataVersion;
|
||||
_lastTimetableSettings = timetableSettings;
|
||||
_lastCustomEvents = widget.customEvents;
|
||||
_cacheKey = key;
|
||||
|
||||
return _cachedAppointments = TimetableAppointmentFactory(
|
||||
lessons: state.getAllKnownLessons().toList(),
|
||||
@@ -79,6 +86,7 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
holidays: state.schoolHolidays?.result ?? const [],
|
||||
settings: timetableSettings,
|
||||
now: DateTime.now(),
|
||||
showClassInsteadOfTeacher: widget.showClassInsteadOfTeacher,
|
||||
).build();
|
||||
}
|
||||
|
||||
@@ -105,7 +113,12 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
disabledColor: Theme.of(context).disabledColor,
|
||||
).build();
|
||||
|
||||
final (minDate, maxDate) = _scrollBounds(state);
|
||||
final capabilities = context.watch<CapabilitiesCubit>();
|
||||
final (minDate, maxDate) = _scrollBounds(
|
||||
state,
|
||||
pastDays: capabilities.timetablePastDays,
|
||||
futureDays: capabilities.timetableFutureDays,
|
||||
);
|
||||
|
||||
return CustomWorkWeekCalendar(
|
||||
key: _calendarKey,
|
||||
@@ -122,17 +135,21 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Hard caps applied on top of whatever Webuntis would allow. Even if the
|
||||
/// school year (or a stale persisted bound) would let the user scroll
|
||||
/// further, we never expose more than this much around the current week.
|
||||
static const int _maxWeeksBack = 4;
|
||||
static const int _maxWeeksForward = 2;
|
||||
|
||||
/// Returns the (minDate, maxDate) the user is allowed to scroll between.
|
||||
/// Starts from the Webuntis school year (or a tight window when that hasn't
|
||||
/// loaded yet), tightens by anything the bloc has learned from past denials,
|
||||
/// and finally clamps to a fixed window around today.
|
||||
(DateTime, DateTime) _scrollBounds(TimetableState state) {
|
||||
/// Starts from the (server-narrowed) Webuntis school year — or a tight window
|
||||
/// when that hasn't loaded yet —, tightens by anything the bloc has learned
|
||||
/// from past denials, and finally clamps to the window Connect grants via
|
||||
/// [CapabilitiesCubit.timetablePastDays]/[CapabilitiesCubit.timetableFutureDays].
|
||||
///
|
||||
/// The capability clamp mirrors the server: `null` means unlimited (no client
|
||||
/// clamp at all), and a given day count is widened to at least cover the
|
||||
/// current Mon–Sun week — so the two windows always coincide and the clamp
|
||||
/// can never invert.
|
||||
(DateTime, DateTime) _scrollBounds(
|
||||
TimetableState state, {
|
||||
required int? pastDays,
|
||||
required int? futureDays,
|
||||
}) {
|
||||
final year = state.schoolyear;
|
||||
final DateTime baseMin;
|
||||
final DateTime baseMax;
|
||||
@@ -154,39 +171,43 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
? state.accessibleEndDate!
|
||||
: baseMax)
|
||||
: baseMax;
|
||||
final todayMonday = _mondayOf(DateTime.now());
|
||||
final cappedMin = effectiveMin.isBefore(
|
||||
todayMonday.subtractDays(_maxWeeksBack * 7),
|
||||
)
|
||||
? todayMonday.subtractDays(_maxWeeksBack * 7)
|
||||
final today = _startOfDay(DateTime.now());
|
||||
final todayMonday = _mondayOf(today);
|
||||
final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1);
|
||||
final capMin = pastDays == null
|
||||
? null
|
||||
: _earlier(today.subtractDays(pastDays), todayMonday);
|
||||
final capMax = futureDays == null
|
||||
? null
|
||||
: _later(today.addDays(futureDays), currentWeekEnd);
|
||||
final cappedMin = capMin != null && effectiveMin.isBefore(capMin)
|
||||
? capMin
|
||||
: effectiveMin;
|
||||
final cappedMax = effectiveMax.isAfter(
|
||||
todayMonday.addDays(_maxWeeksForward * 7 + 6),
|
||||
)
|
||||
? todayMonday.addDays(_maxWeeksForward * 7 + 6)
|
||||
final cappedMax = capMax != null && effectiveMax.isAfter(capMax)
|
||||
? capMax
|
||||
: effectiveMax;
|
||||
// When the resulting range does not cover the current week — the summer gap
|
||||
// between two school years, or a stale persisted bound — fall back to the
|
||||
// full fixed window around today. Otherwise the PageView clamps the initial
|
||||
// page to the last week before the holidays (hiding the "Schulfrei" region)
|
||||
// and forward scrolling collapses to the current week only.
|
||||
final currentWeekEnd = todayMonday.addDays(DateTime.daysPerWeek - 1);
|
||||
// current week, widened to whatever the capabilities still allow. Otherwise
|
||||
// the PageView clamps the initial page to the last week before the holidays
|
||||
// (hiding the "Schulfrei" region) and forward scrolling collapses to the
|
||||
// current week only.
|
||||
final outsideRange =
|
||||
cappedMax.isBefore(todayMonday) || cappedMin.isAfter(currentWeekEnd);
|
||||
final finalMin = outsideRange
|
||||
? todayMonday.subtractDays(_maxWeeksBack * 7)
|
||||
: cappedMin;
|
||||
final finalMax = outsideRange
|
||||
? todayMonday.addDays(_maxWeeksForward * 7 + 6)
|
||||
: cappedMax;
|
||||
final finalMin = outsideRange ? (capMin ?? todayMonday) : cappedMin;
|
||||
final finalMax = outsideRange ? (capMax ?? currentWeekEnd) : cappedMax;
|
||||
final daysToMonday =
|
||||
(DateTime.monday - finalMin.weekday) % DateTime.daysPerWeek;
|
||||
final mondayMin = finalMin.addDays(daysToMonday);
|
||||
return (mondayMin, finalMax);
|
||||
}
|
||||
|
||||
static DateTime _mondayOf(DateTime d) {
|
||||
final monday = d.subtractDays(d.weekday - 1);
|
||||
return DateTime(monday.year, monday.month, monday.day);
|
||||
}
|
||||
static DateTime _mondayOf(DateTime d) =>
|
||||
_startOfDay(d.subtractDays(d.weekday - 1));
|
||||
|
||||
static DateTime _startOfDay(DateTime d) => DateTime(d.year, d.month, d.day);
|
||||
|
||||
static DateTime _earlier(DateTime a, DateTime b) => a.isBefore(b) ? a : b;
|
||||
|
||||
static DateTime _later(DateTime a, DateTime b) => a.isAfter(b) ? a : b;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../utils/downloads/download_job.dart';
|
||||
import '../../utils/downloads/download_manager.dart';
|
||||
import '../../utils/haptics.dart';
|
||||
import 'downloads_sheet.dart';
|
||||
import 'stale_download_guard.dart';
|
||||
|
||||
/// Decides whether a just-finished download should open straight in the viewer.
|
||||
///
|
||||
@@ -198,9 +199,13 @@ class _DownloadTrayHostState extends State<DownloadTrayHost>
|
||||
|
||||
void _openJob(DownloadJob job) {
|
||||
final path = job.localPath;
|
||||
_manager.markOpened(job);
|
||||
final ctx = AppRoutes.overlayContext;
|
||||
if (path == null || ctx == null) return;
|
||||
if (path == null || ctx == null) {
|
||||
_manager.markOpened(job);
|
||||
return;
|
||||
}
|
||||
if (!ensureDownloadStillExists(ctx, job)) return;
|
||||
_manager.markOpened(job);
|
||||
Haptics.success();
|
||||
AppRoutes.openFileViewer(ctx, path, remoteFile: job.remoteFile);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../view/pages/files/data/file_type_icon.dart';
|
||||
import '../centered_leading.dart';
|
||||
import '../details_bottom_sheet.dart';
|
||||
import '../info_dialog.dart';
|
||||
import 'stale_download_guard.dart';
|
||||
|
||||
/// Overview of all active and finished-but-unopened downloads. Lets the user
|
||||
/// open/switch between finished files, cancel running ones and retry failures.
|
||||
@@ -86,8 +87,11 @@ class _DownloadsListState extends State<_DownloadsList> {
|
||||
void _open(DownloadJob job) {
|
||||
final path = job.localPath;
|
||||
if (path == null) return;
|
||||
Haptics.success();
|
||||
// Pop the sheet first so a stale-file dialog lands on the underlying
|
||||
// screen instead of an emptied sheet.
|
||||
Navigator.of(widget.sheetContext).pop();
|
||||
if (!ensureDownloadStillExists(widget.rootContext, job)) return;
|
||||
Haptics.success();
|
||||
DownloadManager.instance.markOpened(job);
|
||||
AppRoutes.openFileViewer(
|
||||
widget.rootContext,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../utils/downloads/download_job.dart';
|
||||
import '../../utils/downloads/download_manager.dart';
|
||||
import '../confirm_dialog.dart';
|
||||
import '../info_dialog.dart';
|
||||
|
||||
/// Verifies a finished download's cache file is still on disk before opening.
|
||||
///
|
||||
/// Downloads live in the app's temp dir, which Android may clear at any time
|
||||
/// (storage pressure, "Cache leeren") — while completion notifications and
|
||||
/// tray entries stay tappable indefinitely. Returns true when the file exists;
|
||||
/// otherwise drops the stale job (incl. its notification) and offers a
|
||||
/// re-download when the remote path is known (it isn't for notification taps
|
||||
/// whose task metadata got lost).
|
||||
bool ensureDownloadStillExists(BuildContext context, DownloadJob job) {
|
||||
final path = job.localPath;
|
||||
if (path == null || File(path).existsSync()) return true;
|
||||
|
||||
DownloadManager.instance.markOpened(job);
|
||||
if (job.remotePath.isNotEmpty) {
|
||||
ConfirmDialog(
|
||||
title: 'Datei nicht mehr verfügbar',
|
||||
content:
|
||||
'Die heruntergeladene Datei wurde vom System aus dem Zwischenspeicher entfernt.\nErneut herunterladen?',
|
||||
confirmButton: 'Herunterladen',
|
||||
onConfirm: () => unawaited(DownloadManager.instance.retry(job)),
|
||||
).asDialog(context);
|
||||
} else {
|
||||
InfoDialog.show(
|
||||
context,
|
||||
'Die heruntergeladene Datei wurde vom System aus dem Zwischenspeicher entfernt. Bitte lade sie erneut herunter.',
|
||||
title: 'Datei nicht mehr verfügbar',
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -14,8 +14,11 @@ import 'package:share_plus/share_plus.dart';
|
||||
import '../routing/app_routes.dart';
|
||||
import '../share_intent/remote_file_ref.dart';
|
||||
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../utils/downloads/download_manager.dart';
|
||||
import 'app_progress_indicator.dart';
|
||||
import 'async_action_button.dart';
|
||||
import 'centered_leading.dart';
|
||||
import 'confirm_dialog.dart';
|
||||
import 'file_viewer/code_line.dart';
|
||||
import 'file_viewer/deferred_pdf_viewer.dart';
|
||||
import 'file_viewer/file_kind.dart';
|
||||
@@ -82,9 +85,43 @@ class _FileViewerState extends State<FileViewer> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Android may clear the cache dir behind an open viewer at any time —
|
||||
/// verify the file is still there before handing its path to an action.
|
||||
bool _ensureLocalFile() {
|
||||
if (File(widget.path).existsSync()) return true;
|
||||
final remote = widget.remoteFile;
|
||||
if (remote == null) {
|
||||
InfoDialog.show(
|
||||
context,
|
||||
'Die Datei wurde vom System aus dem Zwischenspeicher entfernt. Bitte lade sie erneut herunter.',
|
||||
title: 'Datei nicht mehr verfügbar',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
ConfirmDialog(
|
||||
title: 'Datei nicht mehr verfügbar',
|
||||
content:
|
||||
'Die Datei wurde vom System aus dem Zwischenspeicher entfernt.\nErneut herunterladen?',
|
||||
confirmButton: 'Herunterladen',
|
||||
onConfirm: () {
|
||||
// Pop the viewer before starting so the fresh download auto-opens.
|
||||
Navigator.of(context).pop();
|
||||
unawaited(
|
||||
DownloadManager.instance.start(
|
||||
remotePath: remote.path,
|
||||
name: remote.name,
|
||||
remoteFile: remote,
|
||||
),
|
||||
);
|
||||
},
|
||||
).asDialog(context);
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _handleAction(FileViewingActions value) async {
|
||||
switch (value) {
|
||||
case FileViewingActions.openExternal:
|
||||
if (!_ensureLocalFile()) return;
|
||||
AppRoutes.openFileViewer(
|
||||
context,
|
||||
widget.path,
|
||||
@@ -99,16 +136,21 @@ class _FileViewerState extends State<FileViewer> {
|
||||
AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!);
|
||||
break;
|
||||
case FileViewingActions.share:
|
||||
if (!_ensureLocalFile()) return;
|
||||
unawaited(
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(widget.path)],
|
||||
sharePositionOrigin: SharePositionOrigin.get(context),
|
||||
runWithErrorDialog(
|
||||
context,
|
||||
() => SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(widget.path)],
|
||||
sharePositionOrigin: SharePositionOrigin.get(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case FileViewingActions.save:
|
||||
if (!_ensureLocalFile()) return;
|
||||
try {
|
||||
final source = File(widget.path);
|
||||
final size = await source.length();
|
||||
|
||||
@@ -5,8 +5,9 @@ import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
|
||||
import '../route_transition_gate.dart';
|
||||
|
||||
/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push
|
||||
/// animation. Defer until the route enter animation completes.
|
||||
/// SfPdfViewer asserts on `localToGlobal` if laid out while a route
|
||||
/// transition's fresh `RenderTransform` has no size yet. Mount it only while
|
||||
/// the route is at rest (see [RouteTransitionGate]).
|
||||
class DeferredPdfViewer extends StatelessWidget {
|
||||
const DeferredPdfViewer({super.key, required this.path});
|
||||
final String path;
|
||||
|
||||
@@ -2,18 +2,25 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_progress_indicator.dart';
|
||||
|
||||
/// Delays building [builder] until the enclosing route's enter animation has
|
||||
/// finished. Some widgets (notably `SfPdfViewer`) call `localToGlobal` during
|
||||
/// their first layout and assert with `RenderBox was not laid out` when an
|
||||
/// ancestor page-transition `RenderTransform` still has no size mid-push.
|
||||
/// Gating the mount behind the settled animation avoids that race.
|
||||
/// Builds [builder]'s subtree only while the enclosing route is at rest — i.e.
|
||||
/// neither its own enter/exit animation nor its secondary (route-pushed-on-top)
|
||||
/// transition is running.
|
||||
///
|
||||
/// Some widgets (notably `SfPdfViewer`) call `localToGlobal` during layout and
|
||||
/// crash with `RenderBox was not laid out` when an ancestor page-transition
|
||||
/// `RenderTransform` is mid-first-layout. Those transforms are inserted freshly
|
||||
/// whenever a transition *starts* — not only on the initial push, but also on
|
||||
/// pop and when another page is pushed on top. The gate therefore swaps the
|
||||
/// subtree for [placeholder] for the duration of any transition; the status
|
||||
/// listener fires before that frame's layout, so the fragile subtree is gone
|
||||
/// before the new transform lays out.
|
||||
class RouteTransitionGate extends StatefulWidget {
|
||||
const RouteTransitionGate({super.key, required this.builder, this.placeholder});
|
||||
|
||||
final WidgetBuilder builder;
|
||||
|
||||
/// Shown while the route is still animating in. Defaults to a centered
|
||||
/// large progress indicator.
|
||||
/// Shown while the route is transitioning. Defaults to a centered large
|
||||
/// progress indicator.
|
||||
final Widget? placeholder;
|
||||
|
||||
@override
|
||||
@@ -21,36 +28,52 @@ class RouteTransitionGate extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _RouteTransitionGateState extends State<RouteTransitionGate> {
|
||||
bool _ready = false;
|
||||
Animation<double>? _routeAnimation;
|
||||
Animation<double>? _animation;
|
||||
Animation<double>? _secondaryAnimation;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_ready || _routeAnimation != null) return;
|
||||
final animation = ModalRoute.of(context)?.animation;
|
||||
if (animation == null || animation.isCompleted) {
|
||||
_ready = true;
|
||||
return;
|
||||
}
|
||||
_routeAnimation = animation..addStatusListener(_onAnimationStatus);
|
||||
final route = ModalRoute.of(context);
|
||||
_swapListener(route?.animation, _animation, (a) => _animation = a);
|
||||
_swapListener(
|
||||
route?.secondaryAnimation,
|
||||
_secondaryAnimation,
|
||||
(a) => _secondaryAnimation = a,
|
||||
);
|
||||
}
|
||||
|
||||
void _onAnimationStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed && mounted) {
|
||||
setState(() => _ready = true);
|
||||
}
|
||||
void _swapListener(
|
||||
Animation<double>? next,
|
||||
Animation<double>? current,
|
||||
void Function(Animation<double>?) assign,
|
||||
) {
|
||||
if (identical(next, current)) return;
|
||||
current?.removeStatusListener(_onAnimationStatus);
|
||||
assign(next?..addStatusListener(_onAnimationStatus));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_routeAnimation?.removeStatusListener(_onAnimationStatus);
|
||||
_animation?.removeStatusListener(_onAnimationStatus);
|
||||
_secondaryAnimation?.removeStatusListener(_onAnimationStatus);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _transitioning =>
|
||||
_isAnimating(_animation?.status) ||
|
||||
_isAnimating(_secondaryAnimation?.status);
|
||||
|
||||
static bool _isAnimating(AnimationStatus? status) =>
|
||||
status == AnimationStatus.forward || status == AnimationStatus.reverse;
|
||||
|
||||
void _onAnimationStatus(AnimationStatus status) {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_ready) {
|
||||
if (_transitioning) {
|
||||
return widget.placeholder ??
|
||||
const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ abstract class WidgetLesson with _$WidgetLesson {
|
||||
required String subjectShort,
|
||||
String? subjectLong,
|
||||
String? room,
|
||||
/// On teacher accounts this carries the class label ("7a") instead of the
|
||||
/// teacher short name — see `WidgetDataMapper` `showClassInsteadOfTeacher`;
|
||||
/// [originalTeacher] is null in that case.
|
||||
String? teacher,
|
||||
String? originalTeacher,
|
||||
required WidgetLessonStatus status,
|
||||
@@ -61,6 +64,20 @@ abstract class WidgetPeriod with _$WidgetPeriod {
|
||||
_$WidgetPeriodFromJson(json);
|
||||
}
|
||||
|
||||
/// Per-day metadata for the week payload, so native renderers can derive a
|
||||
/// single day's view (including its holiday state) without a day payload.
|
||||
@freezed
|
||||
abstract class WidgetDayInfo with _$WidgetDayInfo {
|
||||
const factory WidgetDayInfo({
|
||||
required DateTime date,
|
||||
@Default(false) bool isHoliday,
|
||||
String? holidayName,
|
||||
}) = _WidgetDayInfo;
|
||||
|
||||
factory WidgetDayInfo.fromJson(Map<String, Object?> json) =>
|
||||
_$WidgetDayInfoFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class WidgetTimetableData with _$WidgetTimetableData {
|
||||
const factory WidgetTimetableData({
|
||||
@@ -73,6 +90,8 @@ abstract class WidgetTimetableData with _$WidgetTimetableData {
|
||||
@Default(<WidgetPeriod>[]) List<WidgetPeriod> periods,
|
||||
@Default(false) bool isHoliday,
|
||||
String? holidayName,
|
||||
/// Week payload only: one entry per day of the covered window.
|
||||
@Default(<WidgetDayInfo>[]) List<WidgetDayInfo> days,
|
||||
}) = _WidgetTimetableData;
|
||||
|
||||
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
|
||||
mixin _$WidgetTimetableData {
|
||||
|
||||
DateTime get fetchedAt;/// The day this widget snapshot is "about" — display anchor.
|
||||
/// For the day variant: the rendered school day.
|
||||
/// For the week variant: the Monday of the rendered school week.
|
||||
DateTime get anchorDate; List<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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -612,16 +882,16 @@ $WidgetTimetableDataCopyWith<WidgetTimetableData> get copyWith => _$WidgetTimeta
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other.lessons, lessons)&&const DeepCollectionEquality().equals(other.periods, periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other.lessons, lessons)&&const DeepCollectionEquality().equals(other.periods, periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other.days, days));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(lessons),const DeepCollectionEquality().hash(periods),isHoliday,holidayName);
|
||||
int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(lessons),const DeepCollectionEquality().hash(periods),isHoliday,holidayName,const DeepCollectionEquality().hash(days));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName)';
|
||||
return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)';
|
||||
}
|
||||
|
||||
|
||||
@@ -632,7 +902,7 @@ abstract mixin class $WidgetTimetableDataCopyWith<$Res> {
|
||||
factory $WidgetTimetableDataCopyWith(WidgetTimetableData value, $Res Function(WidgetTimetableData) _then) = _$WidgetTimetableDataCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
DateTime fetchedAt, DateTime anchorDate, List<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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,Object? days = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,anchorDate: null == anchorDate ? _self.anchorDate : anchorDate // ignore: cast_nullable_to_non_nullable
|
||||
@@ -657,7 +927,8 @@ as DateTime,lessons: null == lessons ? _self.lessons : lessons // ignore: cast_n
|
||||
as List<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 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) {
|
||||
case _WidgetTimetableData() when $default != null:
|
||||
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _:
|
||||
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName,_that.days);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -763,10 +1034,10 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult 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) {
|
||||
case _WidgetTimetableData():
|
||||
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _:
|
||||
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName,_that.days);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -783,10 +1054,10 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult 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) {
|
||||
case _WidgetTimetableData() when $default != null:
|
||||
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _:
|
||||
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName,_that.days);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -798,7 +1069,7 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th
|
||||
@JsonSerializable()
|
||||
|
||||
class _WidgetTimetableData implements WidgetTimetableData {
|
||||
const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required final List<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);
|
||||
|
||||
@override final DateTime fetchedAt;
|
||||
@@ -822,6 +1093,15 @@ class _WidgetTimetableData implements WidgetTimetableData {
|
||||
|
||||
@override@JsonKey() final bool isHoliday;
|
||||
@override final String? holidayName;
|
||||
/// Week payload only: one entry per day of the covered window.
|
||||
final List<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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -836,16 +1116,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other._lessons, _lessons)&&const DeepCollectionEquality().equals(other._periods, _periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other._lessons, _lessons)&&const DeepCollectionEquality().equals(other._periods, _periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other._days, _days));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName);
|
||||
int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName,const DeepCollectionEquality().hash(_days));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName)';
|
||||
return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)';
|
||||
}
|
||||
|
||||
|
||||
@@ -856,7 +1136,7 @@ abstract mixin class _$WidgetTimetableDataCopyWith<$Res> implements $WidgetTimet
|
||||
factory _$WidgetTimetableDataCopyWith(_WidgetTimetableData value, $Res Function(_WidgetTimetableData) _then) = __$WidgetTimetableDataCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
DateTime fetchedAt, DateTime anchorDate, List<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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,Object? days = null,}) {
|
||||
return _then(_WidgetTimetableData(
|
||||
fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,anchorDate: null == anchorDate ? _self.anchorDate : anchorDate // ignore: cast_nullable_to_non_nullable
|
||||
@@ -881,7 +1161,8 @@ as DateTime,lessons: null == lessons ? _self._lessons : lessons // ignore: cast_
|
||||
as List<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 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,
|
||||
};
|
||||
|
||||
_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(
|
||||
fetchedAt: DateTime.parse(json['fetchedAt'] as String),
|
||||
@@ -77,6 +91,11 @@ _WidgetTimetableData _$WidgetTimetableDataFromJson(Map<String, dynamic> json) =>
|
||||
const <WidgetPeriod>[],
|
||||
isHoliday: json['isHoliday'] as bool? ?? false,
|
||||
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(
|
||||
@@ -88,4 +107,5 @@ Map<String, dynamic> _$WidgetTimetableDataToJson(
|
||||
'periods': instance.periods,
|
||||
'isHoliday': instance.isHoliday,
|
||||
'holidayName': instance.holidayName,
|
||||
'days': instance.days,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_time
|
||||
import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
||||
import '../extensions/date_time.dart';
|
||||
import '../view/pages/timetable/data/lesson_labels.dart';
|
||||
import '../view/pages/timetable/data/lesson_merger.dart';
|
||||
import '../view/pages/timetable/data/lesson_period_schedule.dart';
|
||||
import '../view/pages/timetable/data/lesson_status.dart';
|
||||
@@ -34,12 +36,20 @@ class WidgetDataMapper {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
static DateTime resolveWeekAnchor(DateTime now) {
|
||||
final anchor = resolveDayAnchor(now);
|
||||
final monday = anchor.subtract(Duration(days: anchor.weekday - 1));
|
||||
static DateTime resolveWeekAnchor(DateTime now) =>
|
||||
startOfCalendarWeek(resolveDayAnchor(now));
|
||||
|
||||
/// Monday of the calendar week containing [reference] — no roll-forward,
|
||||
/// unlike [resolveWeekAnchor]. Start of the week payload's 14-day window.
|
||||
static DateTime startOfCalendarWeek(DateTime reference) {
|
||||
final monday = reference.subtract(Duration(days: reference.weekday - 1));
|
||||
return DateTime(monday.year, monday.month, monday.day);
|
||||
}
|
||||
|
||||
/// Days covered by the week payload: current calendar week + the next, so
|
||||
/// native renderers can roll the view forward without fresh data.
|
||||
static const int weekWindowDays = 14;
|
||||
|
||||
static WidgetTimetableData buildDayData({
|
||||
required DateTime now,
|
||||
required Iterable<McTimetableEntry> lessons,
|
||||
@@ -49,6 +59,7 @@ class WidgetDataMapper {
|
||||
TimetableGetTimegridResponse? timegrid,
|
||||
GetCustomTimetableEventResponse? customEvents,
|
||||
bool connectDoubleLessons = true,
|
||||
bool showClassInsteadOfTeacher = false,
|
||||
}) {
|
||||
final anchor = resolveDayAnchor(now);
|
||||
final holiday = _findHoliday(anchor, holidays);
|
||||
@@ -59,7 +70,13 @@ class WidgetDataMapper {
|
||||
? LessonMerger.merge(dayLessons)
|
||||
: dayLessons;
|
||||
final mapped = <WidgetLesson>[
|
||||
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
|
||||
..._mapAll(
|
||||
source,
|
||||
now,
|
||||
subjects,
|
||||
rooms,
|
||||
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
|
||||
),
|
||||
..._expandCustomEvents(customEvents, dayStart, dayEnd),
|
||||
]..sort((a, b) => a.start.compareTo(b.start));
|
||||
return WidgetTimetableData(
|
||||
@@ -81,12 +98,17 @@ class WidgetDataMapper {
|
||||
TimetableGetTimegridResponse? timegrid,
|
||||
GetCustomTimetableEventResponse? customEvents,
|
||||
bool connectDoubleLessons = true,
|
||||
bool showClassInsteadOfTeacher = false,
|
||||
}) {
|
||||
final anchor = resolveWeekAnchor(now);
|
||||
final endExclusive = anchor.add(const Duration(days: 5));
|
||||
// The window is anchored at the *current* calendar week, not the
|
||||
// (possibly rolled-forward) week anchor: on Friday evening the payload
|
||||
// must still contain today for renderers that derive day slices.
|
||||
final windowStart = startOfCalendarWeek(now);
|
||||
final endExclusive = windowStart.add(const Duration(days: weekWindowDays));
|
||||
final weekLessons = lessons.where((l) {
|
||||
final dt = l.startDateTime;
|
||||
return !dt.isBefore(anchor) && dt.isBefore(endExclusive);
|
||||
return !dt.isBefore(windowStart) && dt.isBefore(endExclusive);
|
||||
}).toList();
|
||||
// Per-day merge: otherwise a 4th-period lesson on Mon would collapse with
|
||||
// a 1st-period lesson on Tue if subject/teacher match.
|
||||
@@ -94,14 +116,45 @@ class WidgetDataMapper {
|
||||
? _mergePerDay(weekLessons)
|
||||
: weekLessons;
|
||||
final mapped = <WidgetLesson>[
|
||||
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
|
||||
..._expandCustomEvents(customEvents, anchor, endExclusive),
|
||||
..._mapAll(
|
||||
source,
|
||||
now,
|
||||
subjects,
|
||||
rooms,
|
||||
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
|
||||
),
|
||||
..._expandCustomEvents(customEvents, windowStart, endExclusive),
|
||||
]..sort((a, b) => a.start.compareTo(b.start));
|
||||
final days = [
|
||||
for (var i = 0; i < weekWindowDays; i++)
|
||||
_dayInfo(windowStart.addDays(i), holidays),
|
||||
];
|
||||
// The anchor always lies inside the window; the orElse only guards the
|
||||
// impossible.
|
||||
final anchorInfo = days.firstWhere(
|
||||
(d) => d.date == anchor,
|
||||
orElse: () => _dayInfo(anchor, holidays),
|
||||
);
|
||||
return WidgetTimetableData(
|
||||
fetchedAt: now,
|
||||
anchorDate: anchor,
|
||||
lessons: _resolveCollisions(mapped),
|
||||
periods: _resolvePeriods(timegrid),
|
||||
isHoliday: anchorInfo.isHoliday,
|
||||
holidayName: anchorInfo.holidayName,
|
||||
days: days,
|
||||
);
|
||||
}
|
||||
|
||||
static WidgetDayInfo _dayInfo(
|
||||
DateTime day,
|
||||
TimetableGetHolidaysResponse? holidays,
|
||||
) {
|
||||
final holiday = _findHoliday(day, holidays);
|
||||
return WidgetDayInfo(
|
||||
date: day,
|
||||
isHoliday: holiday != null,
|
||||
holidayName: holiday?.longName,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,12 +297,29 @@ class WidgetDataMapper {
|
||||
return [for (final group in byDay.values) ...LessonMerger.merge(group)];
|
||||
}
|
||||
|
||||
static Iterable<WidgetLesson> _mapAll(
|
||||
Iterable<McTimetableEntry> source,
|
||||
DateTime now,
|
||||
TimetableGetSubjectsResponse? subjects,
|
||||
TimetableGetRoomsResponse? rooms, {
|
||||
required bool showClassInsteadOfTeacher,
|
||||
}) => source.map(
|
||||
(l) => _mapLesson(
|
||||
l,
|
||||
now,
|
||||
subjects,
|
||||
rooms,
|
||||
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
|
||||
),
|
||||
);
|
||||
|
||||
static WidgetLesson _mapLesson(
|
||||
McTimetableEntry lesson,
|
||||
DateTime now,
|
||||
TimetableGetSubjectsResponse? subjects,
|
||||
TimetableGetRoomsResponse? rooms,
|
||||
) {
|
||||
TimetableGetRoomsResponse? rooms, {
|
||||
bool showClassInsteadOfTeacher = false,
|
||||
}) {
|
||||
final start = lesson.startDateTime;
|
||||
final end = lesson.endDateTime;
|
||||
final status = _mapStatus(
|
||||
@@ -276,8 +346,14 @@ class WidgetDataMapper {
|
||||
roomName;
|
||||
}
|
||||
final teacher = lesson.teachers.firstOrNull;
|
||||
final teacherName = teacher?.shortName;
|
||||
final originalTeacher = teacher?.originalShortName;
|
||||
// Lehrerpläne: Klasse in den Teacher-Slot mappen, damit die nativen
|
||||
// Renderer unverändert bleiben. Klassenlose Einträge (Aufsichten) behalten
|
||||
// den Lehrer als Fallback.
|
||||
final classLabel = showClassInsteadOfTeacher ? lesson.classLabel : null;
|
||||
final teacherName = classLabel ?? teacher?.shortName;
|
||||
final originalTeacher = classLabel != null
|
||||
? null
|
||||
: teacher?.originalShortName;
|
||||
return WidgetLesson(
|
||||
start: start,
|
||||
end: end,
|
||||
|
||||
@@ -22,14 +22,18 @@ class WidgetPublisher {
|
||||
static Future<void> publishFromBlocState(
|
||||
TimetableState state, {
|
||||
Settings? settings,
|
||||
bool isTeacher = false,
|
||||
}) async {
|
||||
try {
|
||||
final connectDouble =
|
||||
settings?.timetableSettings.connectDoubleLessons ?? true;
|
||||
// Mirror into widget storage so the background isolate sees the same
|
||||
// value the user just toggled.
|
||||
await WidgetSync.setConnectDoubleLessons(connectDouble);
|
||||
await WidgetSync.setThemeMode(_themeName(settings?.appTheme));
|
||||
// values the user just toggled — concurrently, they are independent.
|
||||
await Future.wait([
|
||||
WidgetSync.setConnectDoubleLessons(connectDouble),
|
||||
WidgetSync.setThemeMode(_themeName(settings?.appTheme)),
|
||||
WidgetSync.setIsTeacher(isTeacher),
|
||||
]);
|
||||
final lessons = state.getAllKnownLessons();
|
||||
final now = widgetNow();
|
||||
final dayData = WidgetDataMapper.buildDayData(
|
||||
@@ -41,6 +45,7 @@ class WidgetPublisher {
|
||||
timegrid: state.timegrid,
|
||||
customEvents: state.customEvents,
|
||||
connectDoubleLessons: connectDouble,
|
||||
showClassInsteadOfTeacher: isTeacher,
|
||||
);
|
||||
final weekData = WidgetDataMapper.buildWeekData(
|
||||
now: now,
|
||||
@@ -51,6 +56,7 @@ class WidgetPublisher {
|
||||
timegrid: state.timegrid,
|
||||
customEvents: state.customEvents,
|
||||
connectDoubleLessons: connectDouble,
|
||||
showClassInsteadOfTeacher: isTeacher,
|
||||
);
|
||||
await WidgetSync.writeDayData(dayData);
|
||||
await WidgetSync.writeWeekData(weekData);
|
||||
|
||||
@@ -12,14 +12,18 @@ class WidgetSync {
|
||||
static const String iosAppGroupId =
|
||||
'group.eu.mhsl.marianum.mobile.client.widget';
|
||||
|
||||
static const String iosWidgetKind = 'TimetableWidget';
|
||||
// Must match the WidgetKit `kind` strings declared in
|
||||
// TimetableWidgetExtension.swift — a mismatch makes reloadTimelines a no-op.
|
||||
static const String iosDayWidgetKind = 'TimetableDayWidget';
|
||||
static const String iosWeekWidgetKind = 'TimetableWeekWidget';
|
||||
static const String androidDayProvider = 'TimetableDayWidget';
|
||||
static const String androidWeekProvider = 'TimetableWeekWidget';
|
||||
|
||||
// `_v1` suffix lets a future schema change invalidate stale snapshots
|
||||
// by bumping the key instead of risking a parse crash.
|
||||
// Version suffix lets a schema change invalidate stale snapshots by
|
||||
// bumping the key instead of risking a parse crash.
|
||||
static const String dayDataKey = 'widget_data_day_v1';
|
||||
static const String weekDataKey = 'widget_data_week_v1';
|
||||
// v2: 14-day window + per-day `days` holiday info.
|
||||
static const String weekDataKey = 'widget_data_week_v2';
|
||||
static const String fetchedAtKey = 'widget_data_fetched_at_v1';
|
||||
static const String loggedInKey = 'widget_data_logged_in_v1';
|
||||
// Mirrored into widget storage so the background isolate can read it
|
||||
@@ -27,6 +31,9 @@ class WidgetSync {
|
||||
static const String connectDoubleLessonsKey =
|
||||
'widget_setting_connect_double_lessons_v1';
|
||||
static const String themeModeKey = 'widget_setting_theme_mode_v1';
|
||||
// Mirrored from CapabilitiesCubit so the background isolate can render
|
||||
// teacher plans (class instead of teacher name) without bloc storage.
|
||||
static const String isTeacherKey = 'widget_setting_is_teacher_v1';
|
||||
// Mirrored so the background isolate hits the same Marianum-Connect base
|
||||
// URL the in-app settings cubit currently has selected.
|
||||
static const String marianumConnectBaseUrlKey = 'widget_setting_mc_base_url_v1';
|
||||
@@ -54,25 +61,30 @@ class WidgetSync {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> setLoggedIn(bool loggedIn) async {
|
||||
await ensureInitialized();
|
||||
await HomeWidget.saveWidgetData<bool>(loggedInKey, loggedIn);
|
||||
}
|
||||
static Future<void> setLoggedIn(bool loggedIn) =>
|
||||
_setBool(loggedInKey, loggedIn);
|
||||
|
||||
static Future<void> setConnectDoubleLessons(bool value) async {
|
||||
await ensureInitialized();
|
||||
await HomeWidget.saveWidgetData<bool>(connectDoubleLessonsKey, value);
|
||||
}
|
||||
static Future<void> setConnectDoubleLessons(bool value) =>
|
||||
_setBool(connectDoubleLessonsKey, value);
|
||||
|
||||
/// Default `true` matches `default_settings.dart` — fresh install behaves
|
||||
/// like the in-app calendar.
|
||||
static Future<bool> getConnectDoubleLessons() async {
|
||||
static Future<bool> getConnectDoubleLessons() =>
|
||||
_getBool(connectDoubleLessonsKey, defaultValue: true);
|
||||
|
||||
static Future<void> setIsTeacher(bool value) => _setBool(isTeacherKey, value);
|
||||
|
||||
static Future<bool> getIsTeacher() =>
|
||||
_getBool(isTeacherKey, defaultValue: false);
|
||||
|
||||
static Future<void> _setBool(String key, bool value) async {
|
||||
await ensureInitialized();
|
||||
final value = await HomeWidget.getWidgetData<bool>(
|
||||
connectDoubleLessonsKey,
|
||||
defaultValue: true,
|
||||
);
|
||||
return value ?? true;
|
||||
await HomeWidget.saveWidgetData<bool>(key, value);
|
||||
}
|
||||
|
||||
static Future<bool> _getBool(String key, {required bool defaultValue}) async {
|
||||
await ensureInitialized();
|
||||
return await HomeWidget.getWidgetData<bool>(key) ?? defaultValue;
|
||||
}
|
||||
|
||||
static Future<void> setThemeMode(String mode) async {
|
||||
@@ -90,6 +102,12 @@ class WidgetSync {
|
||||
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 {
|
||||
await ensureInitialized();
|
||||
await HomeWidget.saveWidgetData<String>(dayDataKey, null);
|
||||
@@ -103,11 +121,11 @@ class WidgetSync {
|
||||
try {
|
||||
await HomeWidget.updateWidget(
|
||||
androidName: androidDayProvider,
|
||||
iOSName: iosWidgetKind,
|
||||
iOSName: iosDayWidgetKind,
|
||||
);
|
||||
await HomeWidget.updateWidget(
|
||||
androidName: androidWeekProvider,
|
||||
iOSName: iosWidgetKind,
|
||||
iOSName: iosWeekWidgetKind,
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
log('WidgetSync.triggerUpdate failed: $e');
|
||||
|
||||
+3
-5
@@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration
|
||||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.4.0+59
|
||||
version: 1.5.2+62
|
||||
environment:
|
||||
sdk: ">=3.8.0 <4.0.0"
|
||||
|
||||
@@ -99,15 +99,13 @@ dependencies:
|
||||
app_settings: ^7.0.0
|
||||
flutter_layout_grid: ^2.0.8
|
||||
flutter_markdown_plus: ^1.0.12
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
# Screenshot-Automatisierung für den Play Store: treibt den Demo-Login über
|
||||
# flutter drive und ruft binding.takeScreenshot pro Hauptscreen auf
|
||||
# (integration_test/ + test_driver/, orchestriert von tool/screenshots.sh).
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
fake_async: ^1.3.1
|
||||
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/marianumcloud/login_flow/login_flow_api.dart';
|
||||
|
||||
void main() {
|
||||
group('LoginFlowInit.fromJson', () {
|
||||
test('parses a complete init response', () {
|
||||
final init = LoginFlowInit.fromJson({
|
||||
'poll': {
|
||||
'token': 'abc123',
|
||||
'endpoint': 'https://cloud.example.org/login/v2/poll',
|
||||
},
|
||||
'login': 'https://cloud.example.org/login/v2/flow/xyz',
|
||||
});
|
||||
expect(init.loginUrl, 'https://cloud.example.org/login/v2/flow/xyz');
|
||||
expect(init.pollToken, 'abc123');
|
||||
expect(init.pollEndpoint, 'https://cloud.example.org/login/v2/poll');
|
||||
});
|
||||
|
||||
test('throws on missing login url', () {
|
||||
expect(
|
||||
() => LoginFlowInit.fromJson({
|
||||
'poll': {'token': 't', 'endpoint': 'e'},
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('throws on missing poll token or endpoint', () {
|
||||
expect(
|
||||
() => LoginFlowInit.fromJson({
|
||||
'poll': {'endpoint': 'e'},
|
||||
'login': 'l',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => LoginFlowInit.fromJson({
|
||||
'poll': {'token': 't'},
|
||||
'login': 'l',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => LoginFlowInit.fromJson({'login': 'l'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('LoginFlowCredentials.fromJson', () {
|
||||
test('parses a complete poll response', () {
|
||||
final credentials = LoginFlowCredentials.fromJson({
|
||||
'server': 'https://cloud.example.org',
|
||||
'loginName': 'jdoe',
|
||||
'appPassword': 'secret-app-password',
|
||||
});
|
||||
expect(credentials.server, 'https://cloud.example.org');
|
||||
expect(credentials.loginName, 'jdoe');
|
||||
expect(credentials.appPassword, 'secret-app-password');
|
||||
});
|
||||
|
||||
test('tolerates a missing server field', () {
|
||||
final credentials = LoginFlowCredentials.fromJson({
|
||||
'loginName': 'jdoe',
|
||||
'appPassword': 'secret',
|
||||
});
|
||||
expect(credentials.server, '');
|
||||
});
|
||||
|
||||
test('throws on missing loginName or appPassword', () {
|
||||
expect(
|
||||
() => LoginFlowCredentials.fromJson({'appPassword': 'secret'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => LoginFlowCredentials.fromJson({'loginName': 'jdoe'}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => LoginFlowCredentials.fromJson({
|
||||
'loginName': 'jdoe',
|
||||
'appPassword': '',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('LoginFlowApi.loginNameMatches', () {
|
||||
test('matches case-insensitively and ignores surrounding whitespace', () {
|
||||
expect(
|
||||
LoginFlowApi.loginNameMatches(expected: 'jdoe', actual: 'JDoe'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
LoginFlowApi.loginNameMatches(expected: 'jdoe', actual: ' jdoe '),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a different account', () {
|
||||
expect(
|
||||
LoginFlowApi.loginNameMatches(expected: 'jdoe', actual: 'other'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -53,6 +53,45 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('registrationTypesFor', () {
|
||||
test('password accounts maintain both registrations', () {
|
||||
expect(
|
||||
PushRegistration.registrationTypesFor(
|
||||
usesLoginFlow: false,
|
||||
hasTalkAppPassword: false,
|
||||
),
|
||||
PushRegistrationType.values,
|
||||
);
|
||||
expect(
|
||||
PushRegistration.registrationTypesFor(
|
||||
usesLoginFlow: false,
|
||||
hasTalkAppPassword: true,
|
||||
),
|
||||
PushRegistrationType.values,
|
||||
);
|
||||
});
|
||||
|
||||
test('flow account without talk app password is talk-only', () {
|
||||
expect(
|
||||
PushRegistration.registrationTypesFor(
|
||||
usesLoginFlow: true,
|
||||
hasTalkAppPassword: false,
|
||||
),
|
||||
[PushRegistrationType.talk],
|
||||
);
|
||||
});
|
||||
|
||||
test('flow account with second (talk) app password maintains both', () {
|
||||
expect(
|
||||
PushRegistration.registrationTypesFor(
|
||||
usesLoginFlow: true,
|
||||
hasTalkAppPassword: true,
|
||||
),
|
||||
PushRegistrationType.values,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('pushTokenVariant', () {
|
||||
test('general uses the raw token, talk appends the suffix', () {
|
||||
expect(pushTokenVariant('tok', PushRegistrationType.general), 'tok');
|
||||
|
||||
@@ -17,6 +17,47 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('widget refresh push identified by source + type', () {
|
||||
expect(
|
||||
classifyPush({'source': 'connect', 'type': 'widget-refresh'}),
|
||||
PushKind.widgetRefresh,
|
||||
);
|
||||
});
|
||||
|
||||
test('widget refresh push with reason still classifies', () {
|
||||
expect(
|
||||
classifyPush({
|
||||
'source': 'connect',
|
||||
'type': 'widget-refresh',
|
||||
'reason': 'morning',
|
||||
}),
|
||||
PushKind.widgetRefresh,
|
||||
);
|
||||
});
|
||||
|
||||
test('connect push with unrelated type stays connect', () {
|
||||
expect(
|
||||
classifyPush({'source': 'connect', 'type': 'newsletter', 'title': 'Hi'}),
|
||||
PushKind.connect,
|
||||
);
|
||||
});
|
||||
|
||||
test('nextcloud fields take precedence over widget-refresh type', () {
|
||||
expect(
|
||||
classifyPush({
|
||||
'subject': 'enc',
|
||||
'signature': 'sig',
|
||||
'source': 'connect',
|
||||
'type': 'widget-refresh',
|
||||
}),
|
||||
PushKind.nextcloud,
|
||||
);
|
||||
});
|
||||
|
||||
test('widget-refresh type without connect source is unknown', () {
|
||||
expect(classifyPush({'type': 'widget-refresh'}), PushKind.unknown);
|
||||
});
|
||||
|
||||
test('subject without signature is not a nextcloud push', () {
|
||||
expect(classifyPush({'subject': 'enc'}), PushKind.unknown);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
import 'package:marianum_mobile/storage/timetable_settings.dart';
|
||||
import 'package:marianum_mobile/view/pages/timetable/data/lesson_merger.dart';
|
||||
import 'package:marianum_mobile/view/pages/timetable/data/timetable_appointment_factory.dart';
|
||||
import 'package:marianum_mobile/view/pages/timetable/data/timetable_name_mode.dart';
|
||||
|
||||
McTimetableEntry _lesson({
|
||||
int id = 1,
|
||||
int hour = 8,
|
||||
int minute = 0,
|
||||
List<String> classNames = const ['7a'],
|
||||
List<String> subjects = const ['M'],
|
||||
List<String> rooms = const ['A101'],
|
||||
}) => McTimetableEntry(
|
||||
id: id,
|
||||
date: DateTime(2026, 5, 4),
|
||||
startTime: DateTime(1970, 1, 1, hour, minute),
|
||||
endTime: DateTime(1970, 1, 1, hour, minute + 45),
|
||||
subjects: subjects,
|
||||
teachers: [McTimetableTeacher(shortName: 'MUE', displayName: 'Stefan Müller')],
|
||||
rooms: rooms,
|
||||
classNames: classNames,
|
||||
lessonType: 'LESSON',
|
||||
status: 'REGULAR',
|
||||
substitutionText: null,
|
||||
lessonText: null,
|
||||
infoText: null,
|
||||
);
|
||||
|
||||
final _settings = TimetableSettings(
|
||||
connectDoubleLessons: false,
|
||||
timetableNameMode: TimetableNameMode.name,
|
||||
);
|
||||
|
||||
String _location({
|
||||
required bool showClassInsteadOfTeacher,
|
||||
List<String> classNames = const ['7a'],
|
||||
}) => TimetableAppointmentFactory(
|
||||
lessons: [_lesson(classNames: classNames)],
|
||||
customEvents: const [],
|
||||
subjects: const [],
|
||||
settings: _settings,
|
||||
now: DateTime(2026, 5, 4),
|
||||
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
|
||||
).build().single.location!;
|
||||
|
||||
void main() {
|
||||
group('teacher plan tile label', () {
|
||||
test('shows the teacher surname by default', () {
|
||||
expect(_location(showClassInsteadOfTeacher: false), 'A101\nMüller');
|
||||
});
|
||||
|
||||
test('shows the class instead of the teacher on teacher plans', () {
|
||||
expect(_location(showClassInsteadOfTeacher: true), 'A101\n7a');
|
||||
});
|
||||
|
||||
test('joins multiple classes', () {
|
||||
expect(
|
||||
_location(
|
||||
showClassInsteadOfTeacher: true,
|
||||
classNames: const ['7a', '7b'],
|
||||
),
|
||||
'A101\n7a, 7b',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the teacher when the entry has no class', () {
|
||||
expect(
|
||||
_location(showClassInsteadOfTeacher: true, classNames: const []),
|
||||
'A101\nMüller',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('lesson merger class separation', () {
|
||||
test('does not merge back-to-back lessons of different classes', () {
|
||||
final merged = LessonMerger.merge([
|
||||
_lesson(id: 1, hour: 8, classNames: const ['7a']),
|
||||
_lesson(id: 2, hour: 8, minute: 45, classNames: const ['7b']),
|
||||
]);
|
||||
expect(merged, hasLength(2));
|
||||
});
|
||||
|
||||
test('still merges back-to-back lessons of the same class', () {
|
||||
final merged = LessonMerger.merge([
|
||||
_lesson(id: 1, hour: 8),
|
||||
_lesson(id: 2, hour: 8, minute: 45),
|
||||
]);
|
||||
expect(merged, hasLength(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/widget/route_transition_gate.dart';
|
||||
|
||||
void main() {
|
||||
const gated = Key('gated-child');
|
||||
const placeholder = Key('gate-placeholder');
|
||||
|
||||
Widget gatedPage() => Scaffold(
|
||||
body: RouteTransitionGate(
|
||||
placeholder: const SizedBox(key: placeholder),
|
||||
builder: (_) => const SizedBox(key: gated),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> pumpApp(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Builder(builder: (_) => const Scaffold())),
|
||||
);
|
||||
}
|
||||
|
||||
NavigatorState navigator(WidgetTester tester) =>
|
||||
tester.state<NavigatorState>(find.byType(Navigator));
|
||||
|
||||
testWidgets('shows placeholder during push, child once settled', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpApp(tester);
|
||||
unawaited(
|
||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(find.byKey(placeholder), findsOneWidget);
|
||||
expect(find.byKey(gated), findsNothing);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
expect(find.byKey(placeholder), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('swaps back to placeholder while the route pops', (tester) async {
|
||||
await pumpApp(tester);
|
||||
unawaited(
|
||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
|
||||
navigator(tester).pop();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(find.byKey(gated), findsNothing);
|
||||
expect(find.byKey(placeholder), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('gates during a secondary transition and recovers after pop', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpApp(tester);
|
||||
unawaited(
|
||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
|
||||
unawaited(
|
||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => const Scaffold())),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
// Mid secondary transition the fragile subtree must be unmounted.
|
||||
expect(find.byKey(gated), findsNothing);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
navigator(tester).pop();
|
||||
await tester.pumpAndSettle();
|
||||
// Back at rest on the gated route: child is mounted again.
|
||||
expect(find.byKey(gated), findsOneWidget);
|
||||
expect(find.byKey(placeholder), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -345,15 +345,32 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('startOfCalendarWeek', () {
|
||||
test('Tuesday maps to its own Monday', () {
|
||||
expect(
|
||||
WidgetDataMapper.startOfCalendarWeek(DateTime(2026, 5, 5, 10)),
|
||||
DateTime(2026, 5, 4),
|
||||
);
|
||||
});
|
||||
|
||||
test('Sunday stays in the current week (no roll-forward)', () {
|
||||
expect(
|
||||
WidgetDataMapper.startOfCalendarWeek(DateTime(2026, 5, 10, 22)),
|
||||
DateTime(2026, 5, 4),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('buildWeekData', () {
|
||||
final now = DateTime(2026, 5, 5, 10); // Tuesday
|
||||
|
||||
test('contains lessons across the school week', () {
|
||||
test('covers the 14-day window including next week', () {
|
||||
final lessons = [
|
||||
_lesson(date: DateTime(2026, 5, 4), startHhmm: 800, endHhmm: 845, subjectName: 'MO'),
|
||||
_lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'WE'),
|
||||
_lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'),
|
||||
_lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'),
|
||||
_lesson(date: DateTime(2026, 5, 18), startHhmm: 800, endHhmm: 845, subjectName: 'FAR'),
|
||||
];
|
||||
final data = WidgetDataMapper.buildWeekData(
|
||||
now: now,
|
||||
@@ -365,8 +382,82 @@ void main() {
|
||||
expect(data.anchorDate, DateTime(2026, 5, 4));
|
||||
expect(
|
||||
data.lessons.map((l) => l.subjectShort).toList(),
|
||||
['MO', 'WE', 'FR'],
|
||||
['MO', 'WE', 'FR', 'NEXT'],
|
||||
);
|
||||
});
|
||||
|
||||
test('window stays on the current week when the anchor rolls forward', () {
|
||||
// Friday evening: day/week anchors jump to next Monday, but the window
|
||||
// still starts at the current week's Monday so today stays available.
|
||||
final fridayEvening = DateTime(2026, 5, 8, 18);
|
||||
final lessons = [
|
||||
_lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'),
|
||||
_lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'),
|
||||
];
|
||||
final data = WidgetDataMapper.buildWeekData(
|
||||
now: fridayEvening,
|
||||
lessons: lessons,
|
||||
subjects: null,
|
||||
rooms: null,
|
||||
holidays: null,
|
||||
);
|
||||
expect(data.anchorDate, DateTime(2026, 5, 11));
|
||||
expect(
|
||||
data.lessons.map((l) => l.subjectShort).toList(),
|
||||
['FR', 'NEXT'],
|
||||
);
|
||||
});
|
||||
|
||||
test('carries per-day holiday info in days', () {
|
||||
final holidays = TimetableGetHolidaysResponse(
|
||||
result: [
|
||||
McHoliday(
|
||||
shortName: 'Pfingsten',
|
||||
longName: 'Pfingstferien',
|
||||
startDate: DateTime(2026, 5, 14),
|
||||
endDate: DateTime(2026, 5, 15),
|
||||
),
|
||||
],
|
||||
);
|
||||
final data = WidgetDataMapper.buildWeekData(
|
||||
now: now,
|
||||
lessons: const [],
|
||||
subjects: null,
|
||||
rooms: null,
|
||||
holidays: holidays,
|
||||
);
|
||||
expect(data.days, hasLength(WidgetDataMapper.weekWindowDays));
|
||||
expect(data.days.first.date, DateTime(2026, 5, 4));
|
||||
expect(data.days.last.date, DateTime(2026, 5, 17));
|
||||
final holidayDays =
|
||||
data.days.where((d) => d.isHoliday).map((d) => d.date).toList();
|
||||
expect(holidayDays, [DateTime(2026, 5, 14), DateTime(2026, 5, 15)]);
|
||||
expect(
|
||||
data.days.firstWhere((d) => d.isHoliday).holidayName,
|
||||
'Pfingstferien',
|
||||
);
|
||||
});
|
||||
|
||||
test('sets top-level holiday flags for the anchor day', () {
|
||||
final holidays = TimetableGetHolidaysResponse(
|
||||
result: [
|
||||
McHoliday(
|
||||
shortName: 'Oster',
|
||||
longName: 'Osterferien',
|
||||
startDate: DateTime(2026, 5, 4),
|
||||
endDate: DateTime(2026, 5, 8),
|
||||
),
|
||||
],
|
||||
);
|
||||
final data = WidgetDataMapper.buildWeekData(
|
||||
now: now,
|
||||
lessons: const [],
|
||||
subjects: null,
|
||||
rooms: null,
|
||||
holidays: holidays,
|
||||
);
|
||||
expect(data.isHoliday, isTrue);
|
||||
expect(data.holidayName, 'Osterferien');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user