11 Commits

58 changed files with 2456 additions and 287 deletions
+4
View File
@@ -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,
@@ -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)
}
}
@@ -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"
}
+14
View File
@@ -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
+4
View File
@@ -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 {
+23 -1
View File
@@ -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"
}
+7 -1
View File
@@ -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,
);
}
}
+18 -5
View File
@@ -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 MonSun 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
View File
@@ -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,
),
);
}
+97 -28
View File
@@ -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();
+38
View File
@@ -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(
+33 -1
View File
@@ -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,
+37 -1
View File
@@ -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
+15
View File
@@ -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,
};
+60 -13
View File
@@ -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'),
),
],
],
),
),
),
),
),
),
);
}
}
+19 -2
View File
@@ -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);
}
}
+5 -3
View File
@@ -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 MonSun 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 -2
View File
@@ -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);
}
+5 -1
View File
@@ -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;
}
+46 -4
View File
@@ -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;
+45 -22
View File
@@ -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());
}
+19
View File
@@ -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) =>
+301 -20
View File
@@ -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>,
));
}
+20
View File
@@ -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,
};
+88 -12
View File
@@ -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,
+9 -3
View File
@@ -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);
+38 -20
View File
@@ -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
View File
@@ -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');
+41
View File
@@ -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);
});
}
+93 -2
View File
@@ -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');
});
});
}