added guardian login with views for their assigned childs

This commit is contained in:
2026-09-20 11:11:58 +02:00
parent e4e2b1a4fb
commit 67c935c05b
117 changed files with 4784 additions and 1514 deletions
+14 -6
View File
@@ -1,6 +1,6 @@
# MarianumMobile Client # MarianumMobile Client
Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push). Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files. Zwei Kontoarten: Schul-Konto (Schüler/Lehrer, Benutzername + Passwort) und Eltern-Konto (passwortlos per E-Mail-Code/App-Link, sieht die Stundenpläne der zugeordneten Kinder, kein Talk/Files).
## Stack ## Stack
@@ -16,7 +16,10 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil
``` ```
lib/ lib/
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/) ├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/)
├── session/ Session-Modell (CredentialSession / GuardianSession), SessionManager, SessionLifecycle
├── access/ UserRole, AccessRequirement (Gating von Modulen/Settings)
├── auth_link/ Eltern-Login: App-Link-Listener, Link-Parser, Geräte-Bindung
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) ├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine ├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
├── view/ Screens ├── view/ Screens
@@ -51,6 +54,12 @@ lib/
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc. **Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung.
**Stundenplan:** Ein `TimetableBloc(subject: …)` für alle Fälle (eigener Plan, Fremdplan, Kind). Den globalen Bloc stellt `PrimaryTimetableScope` bereit und tauscht ihn beim Kindwechsel aus; page-scoped Fremdpläne nutzen `ScopedTimetableBloc`. Die Kinderauswahl (`ChildSelectionCubit`) ist modulübergreifend.
## Build / Run ## Build / Run
```bash ```bash
@@ -65,13 +74,12 @@ flutter test # Tests (siehe test
| Backend | Pfad | Zweck | | Backend | Pfad | Zweck |
|---------------------------|-----------------------|----------------------------------------| |---------------------------|-----------------------|----------------------------------------|
| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer | | MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Capabilities, Push |
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung | | Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten | | MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
| Holiday-Calendar | `lib/api/holidays/` | Ferien |
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`). `nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
## Tests ## Tests
`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu. `test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec, Policies, Eltern-Login-Controller). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
+17
View File
@@ -41,6 +41,23 @@
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
<data android:mimeType="application/*" /> <data android:mimeType="application/*" />
</intent-filter> </intent-filter>
<!-- Guardian login mail link (App Link). Verified via
/.well-known/assetlinks.json on both hosts. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="connect.marianum-fulda.de" />
<data android:host="connect-beta.marianum-fulda.de" />
<data android:path="/app/guardian-login" />
</intent-filter>
<!-- The app routes links itself (GuardianLinkListener); Flutter's
built-in handling would push the path as a named route onto a
MaterialApp that only has `home`. -->
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+3 -1
View File
@@ -106,12 +106,14 @@ void _log(String message) => debugPrint('SHOTS: $message');
Future<void> _login(WidgetTester tester) async { Future<void> _login(WidgetTester tester) async {
final loginVisible = await _pumpUntil( final loginVisible = await _pumpUntil(
tester, tester,
find.byKey(const Key('login-username-field')), find.byKey(const Key('login-audience-school')),
); );
if (!loginVisible) { if (!loginVisible) {
_log('kein Login-Screen sichtbar bereits angemeldet, überspringe Login'); _log('kein Login-Screen sichtbar bereits angemeldet, überspringe Login');
return; return;
} }
await tester.tap(find.byKey(const Key('login-audience-school')));
await _pumpUntil(tester, find.byKey(const Key('login-username-field')));
await tester.enterText( await tester.enterText(
find.byKey(const Key('login-username-field')), find.byKey(const Key('login-username-field')),
'demo@screenshots', 'demo@screenshots',
+2
View File
@@ -37,6 +37,8 @@
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
+5
View File
@@ -4,6 +4,11 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:connect.marianum-fulda.de</string>
<string>applinks:connect-beta.marianum-fulda.de</string>
</array>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string> <string>group.eu.mhsl.marianum.mobile.client.widget</string>
+15
View File
@@ -0,0 +1,15 @@
import '../session/session.dart';
/// Backend identity a feature needs. Modules and settings sections declare
/// these; anything whose requirements the session does not meet is hidden.
enum AccessRequirement {
nextcloud;
bool isMetBy(Session? session) => switch (this) {
AccessRequirement.nextcloud => session?.nextcloud != null,
};
}
extension AccessRequirements on Set<AccessRequirement> {
bool areMetBy(Session? session) => every((r) => r.isMetBy(session));
}
+20
View File
@@ -0,0 +1,20 @@
/// Account role as reported by MarianumConnect. Only used for display and for
/// deriving policies; features gate on capabilities and requirements instead
/// of comparing roles.
enum UserRole {
student,
teacher,
staff,
parent,
unknown;
/// Unknown or missing values map to [unknown] so a new server-side role
/// never breaks older app versions.
static UserRole parse(String? wire) => switch (wire) {
'STUDENT' => UserRole.student,
'TEACHER' => UserRole.teacher,
'STAFF' => UserRole.staff,
'PARENT' => UserRole.parent,
_ => UserRole.unknown,
};
}
+23
View File
@@ -1,5 +1,7 @@
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart'; import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart'; import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
import '../../marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../demo_persona.dart';
/// Demo fixtures for the mobile capability flags — everything granted so the /// Demo fixtures for the mobile capability flags — everything granted so the
/// demo persona sees every feature (incl. push) as available and the timetable /// demo persona sees every feature (incl. push) as available and the timetable
@@ -17,6 +19,27 @@ class DemoCapabilities {
userType: 'STUDENT', userType: 'STUDENT',
loaded: true, loaded: true,
); );
/// Guardian persona with two children, so the child switcher is visible.
static CapabilitiesState guardianState() => const CapabilitiesState(
pushNotifications: true,
userType: 'PARENT',
children: [
GuardianChild(
id: 'demo-child-1',
firstName: DemoPersona.studentFirstName,
lastName: 'Hoffmann',
className: DemoPersona.className,
),
GuardianChild(
id: 'demo-child-2',
firstName: 'Jonas',
lastName: 'Hoffmann',
className: '6a',
),
],
loaded: true,
);
} }
/// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive /// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive
+4 -3
View File
@@ -1,4 +1,4 @@
import '../../../model/account_data.dart'; import '../../../session/session_manager.dart';
import '../../marianumcloud/talk/chat/get_chat_response.dart'; import '../../marianumcloud/talk/chat/get_chat_response.dart';
import '../../marianumcloud/talk/room/get_room_response.dart'; import '../../marianumcloud/talk/room/get_room_response.dart';
import '../demo_persona.dart'; import '../demo_persona.dart';
@@ -204,7 +204,8 @@ class DemoTalk {
id: base + 2, id: base + 2,
token: token, token: token,
ago: const Duration(days: 1, hours: 6), ago: const Duration(days: 1, hours: 6),
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?', message:
'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
), ),
_msg( _msg(
id: base + 3, id: base + 3,
@@ -366,7 +367,7 @@ class DemoTalk {
}) => _msg( }) => _msg(
id: id, id: id,
token: token, token: token,
actor: AccountData().getUsername(), actor: SessionManager().requireNextcloud().username,
display: DemoPersona.studentName, display: DemoPersona.studentName,
ago: ago, ago: ago,
message: message, message: message,
+11 -3
View File
@@ -1,4 +1,4 @@
import '../../model/account_data.dart'; import '../../session/session_manager.dart';
/// Central switch for the client-side demo mode. /// Central switch for the client-side demo mode.
/// ///
@@ -8,7 +8,7 @@ import '../../model/account_data.dart';
/// Play reviewers and automated screenshot runs see a fully populated app /// Play reviewers and automated screenshot runs see a fully populated app
/// without a real account and without any network dependency. /// without a real account and without any network dependency.
/// ///
/// The flag is persisted through [AccountData.isDemo], so a demo session /// The flag is persisted through [Session.isDemo], so a demo session
/// survives cold starts exactly like a normal login. It works in release builds /// survives cold starts exactly like a normal login. It works in release builds
/// too — reviewers run the shipped release build. /// too — reviewers run the shipped release build.
class DemoMode { class DemoMode {
@@ -22,6 +22,14 @@ class DemoMode {
static bool matches(String username) => static bool matches(String username) =>
username.trim().toLowerCase().startsWith(usernamePrefix); username.trim().toLowerCase().startsWith(usernamePrefix);
/// Guardian logins are passwordless, so reviewers need a fixed address that
/// skips the mail round-trip. Deliberately not the `demo@` prefix, which a
/// real e-mail address could start with.
static const String guardianEmail = 'demo-eltern@marianum-fulda.de';
static bool matchesGuardian(String email) =>
email.trim().toLowerCase() == guardianEmail;
/// True while the active session is a demo session. /// True while the active session is a demo session.
static bool get active => AccountData().isDemo; static bool get active => SessionManager().isDemo;
} }
+8 -1
View File
@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import '../../session/session.dart';
import '../api_error.dart'; import '../api_error.dart';
import '../http_errors.dart'; import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart'; import '../marianumcloud/talk/talk_error.dart';
@@ -61,7 +62,9 @@ AppException? _dioToAppException(DioException error) {
AppException _dynamiteToAppException(DynamiteApiException error) { AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode; final status = error.statusCode;
final preview = previewBody(error.body); final preview = previewBody(error.body);
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview'; final detail = preview.isEmpty
? 'HTTP $status'
: 'HTTP $status body=$preview';
switch (status) { switch (status) {
case 401: case 401:
return AuthException.unauthorized(technicalDetails: detail); return AuthException.unauthorized(technicalDetails: detail);
@@ -86,6 +89,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error is AppException) return error.userMessage; if (error is AppException) return error.userMessage;
if (error is TalkError) return TalkException(error).userMessage; if (error is TalkError) return TalkException(error).userMessage;
if (error is NextcloudUnavailableException) {
return 'Diese Funktion ist mit deinem Konto nicht verfügbar.';
}
if (error is DioException) { if (error is DioException) {
final mapped = _dioToAppException(error); final mapped = _dioToAppException(error);
@@ -136,6 +142,7 @@ String? errorToTechnicalDetails(Object? error) {
bool errorAllowsRetry(Object? error) { bool errorAllowsRetry(Object? error) {
if (error == null) return true; if (error == null) return true;
if (error is AppException) return error.allowRetry; if (error is AppException) return error.allowRetry;
if (error is NextcloudUnavailableException) return false;
if (error is DioException) { if (error is DioException) {
final mapped = _dioToAppException(error); final mapped = _dioToAppException(error);
if (mapped != null) return mapped.allowRetry; if (mapped != null) return mapped.allowRetry;
@@ -2,13 +2,13 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../model/account_data.dart'; import '../../../session/session_manager.dart';
import '../../http_errors.dart'; import '../../http_errors.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
/// Exchanges the user's real Nextcloud password for a scoped app password via /// Exchanges the user's real Nextcloud password for a scoped app password via
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then /// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]), /// authenticate with the app password (see [NextcloudCredentials.basicAuthHeader]),
/// which is what the push-v2 registration binds to. /// which is what the push-v2 registration binds to.
/// ///
/// Must authenticate with the *real* password — an app password cannot mint /// Must authenticate with the *real* password — an app password cannot mint
@@ -33,7 +33,9 @@ class GetAppPassword {
// Deliberately NOT the shared Authorization value: that one prefers // Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one — // the app password, but an app password cannot mint another one —
// this endpoint requires the real password. // this endpoint requires the real password.
'Authorization': AccountData().getRealPasswordBasicAuthHeader(), 'Authorization': SessionManager()
.requireNextcloud()
.realPasswordBasicAuthHeader,
}, },
), ),
))!; ))!;
@@ -4,8 +4,8 @@ import 'dart:typed_data';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../../session/session_manager.dart';
import '../../errors/parse_exception.dart'; import '../../errors/parse_exception.dart';
import '../../http_errors.dart'; import '../../http_errors.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
@@ -27,12 +27,12 @@ Uri _coreAvatarUri() {
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/'); return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
} }
Uri _userInfoUri() => Uri _userInfoUri() => NextcloudOcs.uri(
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}'); 'cloud/users/${SessionManager().requireNextcloud().username}',
);
Future<http.Response> _send( Future<http.Response> _send(
Future<http.Response> Function(Uri uri, Map<String, String> headers) Future<http.Response> Function(Uri uri, Map<String, String> headers) perform,
perform,
Uri uri, Uri uri,
) async { ) async {
final headers = NextcloudOcs.headers(); final headers = NextcloudOcs.headers();
@@ -98,16 +98,13 @@ class GetUserInfo {
try { try {
final root = jsonDecode(response.body) as Map<String, dynamic>; final root = jsonDecode(response.body) as Map<String, dynamic>;
final data = final data =
(root['ocs'] as Map<String, dynamic>)['data'] (root['ocs'] as Map<String, dynamic>)['data'] as Map<String, dynamic>;
as Map<String, dynamic>;
return CloudUserInfo( return CloudUserInfo(
userId: data['id'] as String, userId: data['id'] as String,
displayName: (data['displayname'] as String?) ?? '', displayName: (data['displayname'] as String?) ?? '',
); );
} catch (e) { } catch (e) {
throw ParseException( throw ParseException(technicalDetails: 'Cloud $uri user info parse: $e');
technicalDetails: 'Cloud $uri user info parse: $e',
);
} }
} }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import '../../model/account_data.dart';
import '../../model/endpoint_data.dart'; import '../../model/endpoint_data.dart';
import '../../session/session_manager.dart';
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by /// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
/// TalkApi, AutocompleteApi, FileSharingApi. /// TalkApi, AutocompleteApi, FileSharingApi.
@@ -16,7 +16,7 @@ class NextcloudOcs {
static Map<String, String> headers() => { static Map<String, String> headers() => {
'Accept': 'application/json', 'Accept': 'application/json',
'OCS-APIRequest': 'true', 'OCS-APIRequest': 'true',
'Authorization': AccountData().getBasicAuthHeader(), 'Authorization': SessionManager().requireNextcloud().basicAuthHeader,
}; };
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) { static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
+7 -7
View File
@@ -1,7 +1,7 @@
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../../session/session_manager.dart';
import '../../api_response.dart'; import '../../api_response.dart';
abstract class WebdavApi<T> { abstract class WebdavApi<T> {
@@ -18,7 +18,7 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps /// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials. /// authenticating with stale credentials.
static Future<WebDavClient> get webdav { static Future<WebDavClient> get webdav {
final secret = AccountData().getNextcloudSecret(); final secret = SessionManager().requireNextcloud().secret;
if (_webdav == null || _webdavSecret != secret) { if (_webdav == null || _webdavSecret != secret) {
_webdavSecret = secret; _webdavSecret = secret;
_webdav = establishWebdavConnection(); _webdav = establishWebdavConnection();
@@ -30,13 +30,13 @@ abstract class WebdavApi<T> {
NextcloudClient( NextcloudClient(
Uri.parse('https://${EndpointData().nextcloud().full()}'), Uri.parse('https://${EndpointData().nextcloud().full()}'),
// App password preferred — with 2FA the real password is not accepted // App password preferred — with 2FA the real password is not accepted
// by Nextcloud at all (see AccountData.usesLoginFlow). // by Nextcloud at all (see NextcloudCredentials.usesLoginFlow).
password: AccountData().getNextcloudSecret(), password: SessionManager().requireNextcloud().secret,
loginName: AccountData().getUsername(), loginName: SessionManager().requireNextcloud().username,
).webdav; ).webdav;
/// Builds the WebDAV download URL without embedded credentials. Callers must /// Builds the WebDAV download URL without embedded credentials. Callers must
/// authenticate via the [AccountData.authHeaders] header instead. /// authenticate via the [NextcloudCredentials.authHeaders] header instead.
static String buildWebdavUrl() => static String buildWebdavUrl() =>
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/'; 'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/';
} }
@@ -1,12 +1,14 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../model/account_data.dart'; import '../../../session/session.dart';
import '../../../session/session_manager.dart';
import '../queries/auth_login/auth_login.dart'; import '../queries/auth_login/auth_login.dart';
import 'device_token_name.dart'; import 'device_token_name.dart';
import 'token_storage.dart'; import 'token_storage.dart';
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401, /// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
/// re-logs in once with the credentials in [AccountData] before retrying. /// renews the token once before retrying. Only password accounts can renew
/// silently; passwordless accounts surface the 401.
class MarianumConnectAuthInterceptor extends Interceptor { class MarianumConnectAuthInterceptor extends Interceptor {
static const _retriedKey = 'mc_auth_retried'; static const _retriedKey = 'mc_auth_retried';
@@ -64,6 +66,9 @@ class MarianumConnectAuthInterceptor extends Interceptor {
} }
final refreshed = await _attemptReLogin(); final refreshed = await _attemptReLogin();
if (!refreshed) { if (!refreshed) {
if (SessionManager().current is GuardianSession) {
SessionManager().reportUnauthorized();
}
handler.next(err); handler.next(err);
return; return;
} }
@@ -87,11 +92,12 @@ class MarianumConnectAuthInterceptor extends Interceptor {
} }
Future<bool> _performReLogin() async { Future<bool> _performReLogin() async {
if (!AccountData().isPopulated()) return false; final session = SessionManager().current;
if (session is! CredentialSession) return false;
try { try {
await _loginClient.run( await _loginClient.run(
username: AccountData().getUsername(), username: session.username,
password: AccountData().getPassword(), password: session.password,
tokenName: await DeviceTokenName.resolve(), tokenName: await DeviceTokenName.resolve(),
); );
return true; return true;
@@ -1,35 +1,38 @@
import 'dart:developer'; import 'dart:developer';
import '../../../model/account_data.dart'; import '../../../session/session.dart';
import '../../../session/session_lifecycle.dart';
import '../../../session/session_manager.dart';
import '../../errors/auth_exception.dart'; import '../../errors/auth_exception.dart';
import '../queries/auth_logout/auth_logout.dart'; import '../queries/auth_me/auth_me.dart';
import '../queries/auth_verify/auth_verify.dart'; import '../queries/auth_verify/auth_verify.dart';
import 'token_storage.dart';
/// Background credential probe a server-side password rotation forces a /// Credential probe. For password accounts a server-side password rotation
/// re-login on the next cold start even when the bearer token would still /// forces a re-login on the next cold start even when the bearer token would
/// be accepted. /// still be accepted; for guardians it confirms a rejected token before the
/// session is dropped.
class SessionValidator { class SessionValidator {
static Future<void> probeStored({ static Future<void> probeStored({
required Future<void> Function() onInvalidated, required Future<void> Function() onInvalidated,
}) async { }) async {
if (!AccountData().isPopulated()) return; final session = SessionManager().current;
// AuthVerify uses its own dio (bypassing the demo interceptor), so a demo // The probes use their own dio (bypassing the demo interceptor), so a demo
// session must be skipped here or its missing token would 401 into a logout. // session must be skipped or its missing token would 401 into a logout.
if (AccountData().isDemo) return; if (session == null || session.isDemo) return;
final username = AccountData().getUsername();
final password = AccountData().getPassword();
try { try {
switch (session) {
case CredentialSession(:final username, :final password):
await AuthVerify().run(username: username, password: password); await AuthVerify().run(username: username, password: password);
case GuardianSession():
await AuthMe().run();
}
} on AuthException catch (e) { } on AuthException catch (e) {
if (e.statusCode != 401) return; if (e.statusCode != 401) return;
log('MC: stored credentials rejected — forcing re-login'); log('MC: stored session rejected — forcing re-login');
await AuthLogout().run(); await SessionLifecycle.signOut();
await const MarianumConnectTokenStorage().clear();
await AccountData().removeData();
await onInvalidated(); await onInvalidated();
} catch (e) { } catch (e) {
log('MC: background credential check failed (transient): $e'); log('MC: background session check failed (transient): $e');
} }
} }
} }
@@ -1,5 +1,8 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../errors/auth_exception.dart';
/// `first_unlock` accessibility so the token can be read during background /// `first_unlock` accessibility so the token can be read during background
/// requests (telemetry heartbeat, push-triggered syncs) after the first device /// requests (telemetry heartbeat, push-triggered syncs) after the first device
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws /// unlock following a reboot. The keychain default (`whenUnlocked`) throws
@@ -9,7 +12,7 @@ const IOSOptions _mcIosOptions = IOSOptions(
); );
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept /// Persists the Marianum-Connect bearer token in the platform keystore. Kept
/// separate from `AccountData` because the username/password live on (Nextcloud /// separate from `SessionManager` because the username/password live on (Nextcloud
/// + MHSL still need them) while the MC token is short-lived and per-endpoint. /// + MHSL still need them) while the MC token is short-lived and per-endpoint.
class MarianumConnectTokenStorage { class MarianumConnectTokenStorage {
static const _tokenKey = 'mc_bearer_token'; static const _tokenKey = 'mc_bearer_token';
@@ -24,6 +27,18 @@ class MarianumConnectTokenStorage {
Future<String?> readToken() => _storage.read(key: _tokenKey); Future<String?> readToken() => _storage.read(key: _tokenKey);
/// Request options carrying the stored token, for probes that bypass the
/// auth interceptor. Throws [AuthException] when no token is stored.
Future<Options> requireBearerOptions(String caller) async {
final token = await readToken();
if (token == null || token.isEmpty) {
throw AuthException.unauthorized(
technicalDetails: '$caller: no bearer token in storage',
);
}
return Options(headers: {'Authorization': 'Bearer $token'});
}
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey); Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
Future<DateTime?> readExpiresAt() async { Future<DateTime?> readExpiresAt() async {
@@ -2,9 +2,14 @@ import '../../marianumconnect_query.dart';
import 'absence_prefill_response.dart'; import 'absence_prefill_response.dart';
/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth). /// GETs the absence-report form prefill (`absence/prefill`, bearer-auth).
/// Guardians pass the [childId] the report is for; the identity then comes
/// from that child.
class AbsencePrefill extends MarianumConnectQuery { class AbsencePrefill extends MarianumConnectQuery {
AbsencePrefill({super.dio}); AbsencePrefill({super.dio});
Future<AbsencePrefillResponse> run() => Future<AbsencePrefillResponse> run({String? childId}) => getObject(
getObject('absence/prefill', AbsencePrefillResponse.fromJson); 'absence/prefill',
AbsencePrefillResponse.fromJson,
queryParameters: {'childId': ?childId},
);
} }
@@ -3,7 +3,8 @@ import '../../marianumconnect_query.dart';
/// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`). /// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`).
/// Empty identity fields are backfilled from LDAP server-side; validation /// Empty identity fields are backfilled from LDAP server-side; validation
/// (all fields required, class must exist, no past start date, end >= start) /// (all fields required, class must exist, no past start date, end >= start)
/// also runs server-side and mirrors the client checks. /// also runs server-side and mirrors the client checks. For guardians the
/// server takes name and class from the child given by [childId].
class AbsenceSubmit extends MarianumConnectQuery { class AbsenceSubmit extends MarianumConnectQuery {
AbsenceSubmit({super.dio}); AbsenceSubmit({super.dio});
@@ -15,6 +16,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
required DateTime absentUntil, required DateTime absentUntil,
required String phone, required String phone,
required String note, required String note,
String? childId,
}) => guard(() async { }) => guard(() async {
await dio.post<void>( await dio.post<void>(
endpoint('absence'), endpoint('absence'),
@@ -26,6 +28,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
'absentUntil': isoDate(absentUntil), 'absentUntil': isoDate(absentUntil),
'phone': phone, 'phone': phone,
'note': note, 'note': note,
'childId': ?childId,
}, },
); );
}); });
@@ -0,0 +1,58 @@
import 'package:dio/dio.dart';
import '../../../../auth_link/pending_guardian_request.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import 'guardian_login_exception.dart';
class AuthGuardianRequestResponse {
final String requestId;
final DateTime expiresAt;
final DateTime resendAvailableAt;
final int codeLength;
const AuthGuardianRequestResponse({
required this.requestId,
required this.expiresAt,
required this.resendAvailableAt,
required this.codeLength,
});
factory AuthGuardianRequestResponse.fromJson(Map<String, dynamic> json) =>
AuthGuardianRequestResponse(
requestId: json['requestId'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
codeLength:
json['codeLength'] as int? ??
PendingGuardianRequest.defaultCodeLength,
);
}
/// Starts a passwordless guardian login: the server mails a code and a link.
/// The answer is identical for unknown addresses, so it reveals nothing about
/// which e-mails are registered.
class AuthGuardianRequest extends MarianumConnectQuery {
AuthGuardianRequest({Dio? dio})
: super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthGuardianRequestResponse> run({
required String email,
required String deviceChallenge,
required String tokenName,
}) async {
try {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/guardian/request'),
data: {
'email': email,
'deviceChallenge': deviceChallenge,
'tokenName': tokenName,
},
);
return AuthGuardianRequestResponse.fromJson(response.data!);
} on DioException catch (e) {
throw GuardianLoginException.fromDio(e);
}
}
}
@@ -0,0 +1,51 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import '../auth_login/auth_login_response.dart';
import 'guardian_login_exception.dart';
/// Completes a guardian login with the mailed code or link token and stores
/// the issued bearer token.
class AuthGuardianVerify extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
AuthGuardianVerify({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthLoginResponse> run({
required String requestId,
required String deviceVerifier,
required String tokenName,
String? code,
String? linkToken,
}) async {
assert((code == null) != (linkToken == null));
try {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/guardian/verify'),
data: {
'requestId': requestId,
'deviceVerifier': deviceVerifier,
'tokenName': tokenName,
'code': ?code,
'linkToken': ?linkToken,
},
);
final payload = AuthLoginResponse.fromJson(response.data!);
await _tokenStorage.write(
token: payload.token,
tokenId: payload.tokenId,
expiresAt: payload.expiresAt,
);
return payload;
} on DioException catch (e) {
throw GuardianLoginException.fromDio(e);
}
}
}
@@ -0,0 +1,126 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../errors/marianumconnect_error.dart';
enum GuardianLoginError {
invalidRequest,
emailNotRegistered,
accountDisabled,
invalidCode,
deviceMismatch,
requestConsumed,
requestExpired,
tooManyAttempts,
rateLimited,
unsupportedServer,
}
/// A rejected guardian login step with the reason the server reported.
class GuardianLoginException extends AppException {
final GuardianLoginError error;
final int? attemptsLeft;
const GuardianLoginException(
this.error, {
required super.userMessage,
this.attemptsLeft,
super.technicalDetails,
}) : super(allowRetry: false);
/// Maps a failed guardian auth call. Only 4xx answers that carry a guardian
/// login reason become [GuardianLoginException]; everything else keeps the
/// generic MarianumConnect mapping (network, 5xx, …).
static AppException fromDio(DioException e) {
final response = e.response;
final status = response?.statusCode;
if (status == null || status < 400 || status >= 500) {
return mapMarianumConnectError(e);
}
final (code, attemptsLeft) = _parseBody(response!.data);
final error = _errorFor(code, status);
if (error == null) return mapMarianumConnectError(e);
return GuardianLoginException(
error,
attemptsLeft: attemptsLeft,
userMessage: messageFor(error, attemptsLeft: attemptsLeft),
technicalDetails: 'MC $status: ${response.data}',
);
}
static String messageFor(
GuardianLoginError error, {
int? attemptsLeft,
}) => switch (error) {
GuardianLoginError.invalidRequest =>
'Bitte gib eine gültige E-Mail-Adresse ein.',
GuardianLoginError.emailNotRegistered =>
'Unter dieser E-Mail-Adresse ist kein Eltern-Zugang hinterlegt. '
'Bitte prüfe, ob die korrekte Adresse verwendet wurde. Bitte wende dich an das Sekretariat, '
'wenn du nicht weißt, welche Adresse für dich registriert ist.',
GuardianLoginError.accountDisabled =>
'Dieser Eltern-Zugang ist derzeit deaktiviert. Bitte wende dich an '
'das Sekretariat.',
GuardianLoginError.invalidCode =>
attemptsLeft == null
? 'Der Code ist falsch.'
: 'Der Code ist falsch. Noch $attemptsLeft '
'${attemptsLeft == 1 ? 'Versuch' : 'Versuche'}.',
GuardianLoginError.deviceMismatch =>
'Dieser Anmeldelink wurde auf einem anderen Gerät angefordert. '
'Bitte gib stattdessen den Code aus der E-Mail ein.',
GuardianLoginError.requestConsumed =>
'Diese Anmeldung wurde bereits verwendet. Bitte fordere einen neuen '
'Code an.',
GuardianLoginError.requestExpired =>
'Der Code ist abgelaufen. Bitte fordere einen neuen an.',
GuardianLoginError.tooManyAttempts =>
'Zu viele Fehlversuche. Bitte fordere einen neuen Code an.',
GuardianLoginError.rateLimited =>
'Zu viele Anfragen. Bitte warte einige Stunden und versuche es '
'erneut.',
GuardianLoginError.unsupportedServer =>
'Die Eltern-Anmeldung ist auf diesem Server noch nicht verfügbar. '
'Bitte versuche es später erneut.',
};
/// Accepts the documented JSON body (`{"error": …, "attemptsLeft": …}`) as
/// well as the plain-text `Fehler: <code>` the server's generic error
/// handler produces.
static (String?, int?) _parseBody(Object? data) {
if (data is Map) {
final attempts = data['attemptsLeft'];
return (data['error'] as String?, attempts is int ? attempts : null);
}
if (data is String) {
final code = data.startsWith('Fehler: ')
? data.substring('Fehler: '.length)
: data;
return (code.trim(), null);
}
return (null, null);
}
static GuardianLoginError? _errorFor(String? code, int status) =>
switch (code) {
'invalid_request' => GuardianLoginError.invalidRequest,
'email_not_registered' => GuardianLoginError.emailNotRegistered,
'account_disabled' => GuardianLoginError.accountDisabled,
'invalid_code' => GuardianLoginError.invalidCode,
'device_mismatch' => GuardianLoginError.deviceMismatch,
'request_consumed' => GuardianLoginError.requestConsumed,
'request_expired' => GuardianLoginError.requestExpired,
'too_many_attempts' => GuardianLoginError.tooManyAttempts,
_ => switch (status) {
401 => GuardianLoginError.invalidCode,
// The server names an unknown address explicitly
// (`email_not_registered`); a bare 404/405 means the endpoint itself
// is missing, i.e. a server version without guardian login.
404 || 405 => GuardianLoginError.unsupportedServer,
409 => GuardianLoginError.requestConsumed,
410 => GuardianLoginError.requestExpired,
429 => GuardianLoginError.rateLimited,
_ => null,
},
};
}
@@ -0,0 +1,30 @@
import 'package:dio/dio.dart';
import '../../../errors/auth_exception.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
/// Probes that the stored bearer token is still accepted. Used for accounts
/// without a password (guardians), whose token cannot be renewed silently.
///
/// Bypasses the shared dio singleton so the auth interceptor does not react
/// to the 401 this probe is meant to observe.
class AuthMe extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
AuthMe({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? MarianumConnectApi.plainDio());
/// Throws [AuthException] when the token is missing or rejected.
Future<void> run() async {
final options = await _tokenStorage.requireBearerOptions('AuthMe');
return guard(() async {
await dio.get<void>(endpoint('auth/me'), options: options);
});
}
}
@@ -29,17 +29,12 @@ class AuthVerify extends MarianumConnectQuery {
required String username, required String username,
required String password, required String password,
}) async { }) async {
final token = await _tokenStorage.readToken(); final options = await _tokenStorage.requireBearerOptions('AuthVerify');
if (token == null || token.isEmpty) {
throw AuthException.unauthorized(
technicalDetails: 'AuthVerify: no bearer token in storage',
);
}
return guard(() async { return guard(() async {
await dio.post<void>( await dio.post<void>(
endpoint('auth/verify'), endpoint('auth/verify'),
data: {'username': username, 'password': password}, data: {'username': username, 'password': password},
options: Options(headers: {'Authorization': 'Bearer $token'}), options: options,
); );
}); });
} }
@@ -1,5 +1,7 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'guardian_child.dart';
part 'get_capabilities_response.g.dart'; part 'get_capabilities_response.g.dart';
/// Slimmed-down capability flags the mobile UI gates features on. The backend /// Slimmed-down capability flags the mobile UI gates features on. The backend
@@ -23,16 +25,21 @@ class CapabilitiesResponse {
final int? timetableFutureDays; final int? timetableFutureDays;
/// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend /// User type ('TEACHER' | 'STUDENT' | 'STAFF' | 'PARENT'). Null when the
/// predates the field or has no LDAP record for the user. /// backend predates the field or has no record for the user.
final String? userType; final String? userType;
/// Students linked to a guardian account; empty for everyone else.
@JsonKey(defaultValue: <GuardianChild>[])
final List<GuardianChild> children;
CapabilitiesResponse({ CapabilitiesResponse({
required this.viewForeignTimetables, required this.viewForeignTimetables,
required this.pushNotifications, required this.pushNotifications,
this.timetablePastDays, this.timetablePastDays,
this.timetableFutureDays, this.timetableFutureDays,
this.userType, this.userType,
this.children = const [],
}); });
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) => factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
@@ -14,6 +14,11 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
userType: json['userType'] as String?, userType: json['userType'] as String?,
children:
(json['children'] as List<dynamic>?)
?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
); );
Map<String, dynamic> _$CapabilitiesResponseToJson( Map<String, dynamic> _$CapabilitiesResponseToJson(
@@ -24,4 +29,5 @@ Map<String, dynamic> _$CapabilitiesResponseToJson(
'timetablePastDays': instance.timetablePastDays, 'timetablePastDays': instance.timetablePastDays,
'timetableFutureDays': instance.timetableFutureDays, 'timetableFutureDays': instance.timetableFutureDays,
'userType': instance.userType, 'userType': instance.userType,
'children': instance.children,
}; };
@@ -0,0 +1,23 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'guardian_child.freezed.dart';
part 'guardian_child.g.dart';
/// A student linked to the signed-in guardian. [id] is an opaque server id,
/// not a WebUntis id.
@freezed
abstract class GuardianChild with _$GuardianChild {
const GuardianChild._();
const factory GuardianChild({
required String id,
required String firstName,
required String lastName,
@Default('') String className,
}) = _GuardianChild;
factory GuardianChild.fromJson(Map<String, Object?> json) =>
_$GuardianChildFromJson(json);
String get displayName => '$firstName $lastName'.trim();
}
@@ -0,0 +1,294 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'guardian_child.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$GuardianChild {
String get id; String get firstName; String get lastName; String get className;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$GuardianChildCopyWith<GuardianChild> get copyWith => _$GuardianChildCopyWithImpl<GuardianChild>(this as GuardianChild, _$identity);
/// Serializes this GuardianChild to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
final _this = this as GuardianChild;
return identical(this, other) || (other.runtimeType == runtimeType&&other is GuardianChild&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.firstName, _this.firstName) || other.firstName == _this.firstName)&&(identical(other.lastName, _this.lastName) || other.lastName == _this.lastName)&&(identical(other.className, _this.className) || other.className == _this.className));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as GuardianChild;
return Object.hash(runtimeType,_this.id,_this.firstName,_this.lastName,_this.className);
}
@override
String toString() {
final _this = this as GuardianChild;
return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className})';
}
}
/// @nodoc
abstract mixin class $GuardianChildCopyWith<$Res> {
factory $GuardianChildCopyWith(GuardianChild value, $Res Function(GuardianChild) _then) = _$GuardianChildCopyWithImpl;
@useResult
$Res call({
String id, String firstName, String lastName, String className
});
}
/// @nodoc
class _$GuardianChildCopyWithImpl<$Res>
implements $GuardianChildCopyWith<$Res> {
_$GuardianChildCopyWithImpl(this._self, this._then);
final GuardianChild _self;
final $Res Function(GuardianChild) _then;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,}) {
return _then(GuardianChild(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [GuardianChild].
extension GuardianChildPatterns on GuardianChild {
/// 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( _GuardianChild value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _GuardianChild() 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( _GuardianChild value) $default,){
final _that = this;
switch (_that) {
case _GuardianChild():
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( _GuardianChild value)? $default,){
final _that = this;
switch (_that) {
case _GuardianChild() 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( String id, String firstName, String lastName, String className)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that.id,_that.firstName,_that.lastName,_that.className);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( String id, String firstName, String lastName, String className) $default,) {final _that = this;
switch (_that) {
case _GuardianChild():
return $default(_that.id,_that.firstName,_that.lastName,_that.className);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( String id, String firstName, String lastName, String className)? $default,) {final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that.id,_that.firstName,_that.lastName,_that.className);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _GuardianChild extends GuardianChild {
const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = ''}): super._();
factory _GuardianChild.fromJson(Map<String, dynamic> json) => _$GuardianChildFromJson(json);
@override final String id;
@override final String firstName;
@override final String lastName;
@override@JsonKey() final String className;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$GuardianChildCopyWith<_GuardianChild> get copyWith => __$GuardianChildCopyWithImpl<_GuardianChild>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$GuardianChildToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GuardianChild&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.className, className) || other.className == className));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,id,firstName,lastName,className);
}
@override
String toString() {
return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className)';
}
}
/// @nodoc
abstract mixin class _$GuardianChildCopyWith<$Res> implements $GuardianChildCopyWith<$Res> {
factory _$GuardianChildCopyWith(_GuardianChild value, $Res Function(_GuardianChild) _then) = __$GuardianChildCopyWithImpl;
@override @useResult
$Res call({
String id, String firstName, String lastName, String className
});
}
/// @nodoc
class __$GuardianChildCopyWithImpl<$Res>
implements _$GuardianChildCopyWith<$Res> {
__$GuardianChildCopyWithImpl(this._self, this._then);
final _GuardianChild _self;
final $Res Function(_GuardianChild) _then;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,}) {
return _then(_GuardianChild(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on
@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'guardian_child.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_GuardianChild _$GuardianChildFromJson(Map<String, dynamic> json) =>
_GuardianChild(
id: json['id'] as String,
firstName: json['firstName'] as String,
lastName: json['lastName'] as String,
className: json['className'] as String? ?? '',
);
Map<String, dynamic> _$GuardianChildToJson(_GuardianChild instance) =>
<String, dynamic>{
'id': instance.id,
'firstName': instance.firstName,
'lastName': instance.lastName,
'className': instance.className,
};
@@ -1,16 +1,18 @@
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Registers (upserts) this device's push subscription with MarianumConnect via /// Registers (upserts) this device's push subscription with MarianumConnect via
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud /// `PUT /api/mobile/v1/me/push-device`. For Nextcloud registrations the backend
/// device-identifier signature, stores the routing metadata and starts /// verifies the device-identifier signature and forwards Nextcloud pushes to
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204. /// this device's FCM token; `direct` registrations (accounts without
/// Nextcloud) carry no signature and only receive MarianumConnect pushes.
/// Responds 204.
class PushDeviceRegister extends MarianumConnectQuery { class PushDeviceRegister extends MarianumConnectQuery {
PushDeviceRegister({super.dio}); PushDeviceRegister({super.dio});
Future<void> run({ Future<void> run({
required String deviceIdentifier, required String deviceIdentifier,
required String deviceIdentifierSignature, String? deviceIdentifierSignature,
required String userPublicKey, String? userPublicKey,
required String pushToken, required String pushToken,
required String platform, required String platform,
required String registrationType, required String registrationType,
@@ -20,12 +22,13 @@ class PushDeviceRegister extends MarianumConnectQuery {
endpoint('me/push-device'), endpoint('me/push-device'),
data: { data: {
'deviceIdentifier': deviceIdentifier, 'deviceIdentifier': deviceIdentifier,
'deviceIdentifierSignature': deviceIdentifierSignature, 'deviceIdentifierSignature': ?deviceIdentifierSignature,
'userPublicKey': userPublicKey, 'userPublicKey': ?userPublicKey,
'pushToken': pushToken, 'pushToken': pushToken,
'platform': platform, 'platform': platform,
// 'general' | 'talk' — the backend derives the NC hash comparison // 'general' | 'talk' — the backend derives the NC hash comparison
// value from it (general = sha512(token), talk = sha512(token+'#talk')). // value from it (general = sha512(token), talk = sha512(token+'#talk')).
// 'direct' — no Nextcloud subscription behind it.
'registrationType': registrationType, 'registrationType': registrationType,
'appVersion': ?appVersion, 'appVersion': ?appVersion,
}, },
@@ -1,7 +1,7 @@
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../../utils/random_id.dart';
/// A stable, anonymous per-install identifier for telemetry. Generated once on /// A stable, anonymous per-install identifier for telemetry. Generated once on
/// first use (128 bits from a cryptographic RNG) and persisted in the secure /// first use (128 bits from a cryptographic RNG) and persisted in the secure
/// keystore, so a device stays a single row across password rotations and FCM /// keystore, so a device stays a single row across password rotations and FCM
@@ -21,15 +21,9 @@ class TelemetryDeviceId {
_cached = existing; _cached = existing;
return existing; return existing;
} }
final generated = _generate(); final generated = randomHexId();
await _storage.write(key: _key, value: generated); await _storage.write(key: _key, value: generated);
_cached = generated; _cached = generated;
return generated; return generated;
} }
static String _generate() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
} }
@@ -2,8 +2,8 @@ import 'dart:developer';
import 'package:localstore/localstore.dart'; import 'package:localstore/localstore.dart';
import '../../../../model/account_data.dart'; import '../../../../session/session.dart';
import '../../../demo/demo_mode.dart'; import '../../../../session/session_manager.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart'; import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart'; import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
@@ -28,12 +28,15 @@ class CustomEventsMigration {
const CustomEventsMigration._(); const CustomEventsMigration._();
static Future<void> runOnce() async { static Future<void> runOnce() async {
if (DemoMode.active) return; // Guardians never had MHSL events; only password accounts can derive the
// legacy identity.
final session = SessionManager().current;
if (session is! CredentialSession || session.isDemo) return;
if (await _isDone()) return; if (await _isDone()) return;
try { try {
final response = await GetCustomTimetableEvent( final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(AccountData().getUserSecret()), GetCustomTimetableEventParams(session.legacyUserSecret),
).run(); ).run();
for (final event in response.events) { for (final event in response.events) {
@@ -44,7 +47,9 @@ class CustomEventsMigration {
} }
await _markDone(); await _markDone();
log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.'); log(
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
);
} catch (e) { } catch (e) {
// Leave the flag unset so the next launch retries; the delete-after-post // Leave the flag unset so the next launch retries; the delete-after-post
// above keeps a partial run duplicate-free. // above keeps a partial run duplicate-free.
@@ -0,0 +1,18 @@
import '../../marianumconnect_query.dart';
import '../timetable_get_week/timetable_get_week_response.dart';
/// Fetches the weekly timetable of a guardian's child from
/// `timetable/child/{childId}`. Same response shape as `timetable/me`.
class TimetableGetChildWeek extends MarianumConnectQuery {
TimetableGetChildWeek({super.dio});
Future<TimetableGetWeekResponse> run({
required String childId,
required DateTime from,
required DateTime until,
}) => getObject(
'timetable/child/${Uri.encodeComponent(childId)}',
TimetableGetWeekResponse.fromJson,
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
);
}
+57 -34
View File
@@ -15,7 +15,9 @@ import 'notification/notification_tasks.dart';
import 'push/push_registration.dart'; import 'push/push_registration.dart';
import 'push/push_tap_router.dart'; import 'push/push_tap_router.dart';
import 'routing/app_routes.dart'; import 'routing/app_routes.dart';
import 'session/session_manager.dart';
import 'share_intent/share_intent_listener.dart'; import 'share_intent/share_intent_listener.dart';
import 'state/app/infrastructure/loadable_state/loadable_state.dart';
import 'state/app/modules/app_modules.dart'; import 'state/app/modules/app_modules.dart';
import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
@@ -23,13 +25,16 @@ import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
import 'state/app/modules/timetable/bloc/timetable_state.dart'; import 'state/app/modules/timetable/bloc/timetable_state.dart';
import 'state/app/modules/timetable/policy/timetable_policy.dart';
import 'storage/settings.dart' as model; import 'storage/settings.dart' as model;
import 'utils/debouncer.dart'; import 'utils/debouncer.dart';
import 'utils/haptics.dart'; import 'utils/haptics.dart';
import 'view/pages/overhang.dart'; import 'view/pages/overhang.dart';
import 'widget/breaker/breaker.dart'; import 'widget/breaker/breaker.dart';
import 'widget/info_dialog.dart';
import 'widget_data/widget_navigation.dart'; import 'widget_data/widget_navigation.dart';
import 'widget_data/widget_publisher.dart'; import 'widget_data/widget_publisher.dart';
import 'widget_data/widget_sync.dart';
class App extends StatefulWidget { class App extends StatefulWidget {
const App({super.key}); const App({super.key});
@@ -40,7 +45,6 @@ class App extends StatefulWidget {
class _AppState extends State<App> with WidgetsBindingObserver { class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _updateTimings; late Timer _updateTimings;
StreamSubscription<dynamic>? _timetableWidgetSync;
StreamSubscription<RemoteMessage>? _onMessageSub; StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub; StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub; StreamSubscription<String>? _fcmTokenRefreshSub;
@@ -142,12 +146,43 @@ class _AppState extends State<App> with WidgetsBindingObserver {
AppRoutes.goToTab(context, Modules.timetable); AppRoutes.goToTab(context, Modules.timetable);
} }
/// Mirrors the primary plan into the home-screen widget without waiting
/// for the periodic background refresh.
void _publishWidget(TimetableBloc bloc) {
final data = bloc.state.data;
if (!mounted || data is! TimetableState) return;
if (WidgetSync.encodeSubject(bloc.subject) == null) return;
unawaited(
WidgetPublisher.publishFromBlocState(
data,
subject: bloc.subject,
settings: context.read<SettingsCubit>().val(),
showClassInsteadOfTeacher: TimetablePolicy.resolve(
subject: bloc.subject,
capabilities: context.read<CapabilitiesCubit>().state,
).showClassInsteadOfTeacher,
),
);
}
void _handlePendingShare() { void _handlePendingShare() {
if (!mounted) return; if (!mounted) return;
final share = ShareIntentListener.pending.value; final share = ShareIntentListener.pending.value;
if (share == null) return; if (share == null) return;
// A second share would otherwise leave the previous share-flow page // A second share would otherwise leave the previous share-flow page
// on top with stale (already-cleared) file paths. // on top with stale (already-cleared) file paths.
// Sharing targets Talk chats and Files folders only.
final session = SessionManager().current;
if (!AppModule.isAvailableFor(Modules.talk, session) &&
!AppModule.isAvailableFor(Modules.files, session)) {
ShareIntentListener.instance.clear();
InfoDialog.show(
context,
'Mit diesem Konto können keine Inhalte in die App geteilt werden.',
title: 'Teilen nicht möglich',
);
return;
}
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
@@ -168,37 +203,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return; if (!mounted) return;
context.read<BreakerBloc>().refresh(); context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
// Re-mounts on every login, so this also covers post-logout state reset. // Initial publish in case hydrated storage already has data. No refresh
final timetable = context.read<TimetableBloc>(); // needed: PrimaryTimetableScope hands out a freshly loading bloc.
timetable.refresh(); _publishWidget(context.read<TimetableBloc>());
// 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;
if (data is TimetableState && !state.isLoading) {
unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
),
);
}
});
// Initial publish in case hydrated storage already has data.
final initialData = timetable.state.data;
if (initialData is TimetableState) {
unawaited(
WidgetPublisher.publishFromBlocState(
initialData,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
),
);
}
unawaited(_handlePendingWidgetNavigation()); unawaited(_handlePendingWidgetNavigation());
ShareIntentListener.instance.attach(); ShareIntentListener.instance.attach();
ShareIntentListener.pending.addListener(_handlePendingShare); ShareIntentListener.pending.addListener(_handlePendingShare);
@@ -254,7 +261,6 @@ class _AppState extends State<App> with WidgetsBindingObserver {
@override @override
void dispose() { void dispose() {
_updateTimings.cancel(); _updateTimings.cancel();
_timetableWidgetSync?.cancel();
_onMessageSub?.cancel(); _onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel(); _onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel(); _fcmTokenRefreshSub?.cancel();
@@ -268,7 +274,24 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
@override @override
Widget build( Widget build(BuildContext context) =>
BlocListener<TimetableBloc, LoadableState<TimetableState>>(
// Also follows a swapped instance (child switch), unlike a manual
// stream subscription.
listenWhen: (_, state) => !state.isLoading,
// A week change emits several times (week, prefetched neighbours);
// one widget reload for the burst is enough.
listener: (_, _) => Debouncer.debounce(
'widgetPublish',
const Duration(milliseconds: 500),
() {
if (mounted) _publishWidget(context.read<TimetableBloc>());
},
),
child: _buildShell(context),
);
Widget _buildShell(
BuildContext context, BuildContext context,
) => BlocBuilder<SettingsCubit, model.Settings>( ) => BlocBuilder<SettingsCubit, model.Settings>(
builder: (context, _) { builder: (context, _) {
+22
View File
@@ -0,0 +1,22 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
/// Binds a guardian login request to the device that started it (PKCE-style):
/// the request carries only [challengeFor] of a secret that never leaves the
/// device, verification sends the secret itself. A mail link opened on another
/// device therefore cannot complete the login.
abstract final class DeviceBinding {
static String generateSecret([Random? random]) {
final rng = random ?? Random.secure();
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
return _base64UrlNoPad(bytes);
}
static String challengeFor(String secret) =>
_base64UrlNoPad(sha256.convert(utf8.encode(secret)).bytes);
static String _base64UrlNoPad(List<int> bytes) =>
base64Url.encode(bytes).replaceAll('=', '');
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:app_links/app_links.dart';
import 'package:flutter/foundation.dart';
import 'guardian_login_link.dart';
/// Bridges incoming App Links / Universal Links into [pending]; the login
/// screen consumes it. Mirrors ShareIntentListener: [initialize] reads the
/// cold-start link before `runApp` and then follows links while running.
///
/// Links are kept raw and parsed on consumption: the endpoint setting the
/// link is checked against is only applied once the app is built.
class GuardianLinkListener {
GuardianLinkListener._();
static final GuardianLinkListener instance = GuardianLinkListener._();
static final ValueNotifier<Uri?> pending = ValueNotifier(null);
final AppLinks _appLinks = AppLinks();
bool _listening = false;
Future<void> initialize() async {
try {
final initial = await _appLinks.getInitialLink();
if (initial != null) _publish(initial);
} catch (e) {
debugPrint('GuardianLinkListener.initialize failed: $e');
}
if (_listening) return;
_listening = true;
// Kept for the whole process lifetime; links can arrive at any time.
_appLinks.uriLinkStream.listen(
_publish,
onError: (Object e) => debugPrint('GuardianLinkListener error: $e'),
);
}
// Cheap pre-filter; the host check against the active endpoint happens in
// GuardianLoginLink.parse.
void _publish(Uri uri) {
if (!uri.path.endsWith(GuardianLoginLink.path)) return;
pending.value = uri;
}
static void clear() => pending.value = null;
}
+26
View File
@@ -0,0 +1,26 @@
/// A sign-in link from the guardian login mail
/// (`https://<connect-host>/app/guardian-login?rid=…&lt=…`).
class GuardianLoginLink {
static const path = '/app/guardian-login';
final String requestId;
final String linkToken;
const GuardianLoginLink({required this.requestId, required this.linkToken});
/// Parses [uri] if it is a guardian login link for the server at [apiBase].
/// Links of another server (e.g. live link while the app points at beta)
/// are rejected: the request only exists on the server that sent the mail.
static GuardianLoginLink? parse(Uri uri, {required Uri apiBase}) {
if (uri.scheme != 'https' || uri.host != apiBase.host) return null;
final basePath = apiBase.path.endsWith('/')
? apiBase.path.substring(0, apiBase.path.length - 1)
: apiBase.path;
if (uri.path != '$basePath$path') return null;
final requestId = uri.queryParameters['rid'];
final linkToken = uri.queryParameters['lt'];
if (requestId == null || requestId.isEmpty) return null;
if (linkToken == null || linkToken.isEmpty) return null;
return GuardianLoginLink(requestId: requestId, linkToken: linkToken);
}
}
@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// A guardian login request waiting for its code or link. Persisted because
/// Android often kills the app while the user reads the mail.
class PendingGuardianRequest {
static const int defaultCodeLength = 6;
final String requestId;
final String email;
final String deviceSecret;
final DateTime expiresAt;
final DateTime resendAvailableAt;
final int codeLength;
const PendingGuardianRequest({
required this.requestId,
required this.email,
required this.deviceSecret,
required this.expiresAt,
required this.resendAvailableAt,
this.codeLength = defaultCodeLength,
});
bool isExpired(DateTime now) => !now.isBefore(expiresAt);
Map<String, Object> toJson() => {
'requestId': requestId,
'email': email,
'deviceSecret': deviceSecret,
'expiresAt': expiresAt.toIso8601String(),
'resendAvailableAt': resendAvailableAt.toIso8601String(),
'codeLength': codeLength,
};
static PendingGuardianRequest? fromJson(Map<String, dynamic> json) {
try {
return PendingGuardianRequest(
requestId: json['requestId'] as String,
email: json['email'] as String,
deviceSecret: json['deviceSecret'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
codeLength: json['codeLength'] as int? ?? defaultCodeLength,
);
} on Object {
return null;
}
}
}
class PendingGuardianRequestStore {
static const _key = 'guardian_login_pending_request';
static const FlutterSecureStorage _storage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
const PendingGuardianRequestStore();
Future<PendingGuardianRequest?> read() async {
try {
final raw = await _storage.read(key: _key);
if (raw == null) return null;
return PendingGuardianRequest.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} on Object {
return null;
}
}
Future<void> write(PendingGuardianRequest request) =>
_storage.write(key: _key, value: jsonEncode(request.toJson()));
Future<void> clear() async {
try {
await _storage.delete(key: _key);
} on Object {
// Nothing stored or keystore unavailable.
}
}
}
+53 -22
View File
@@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
@@ -14,11 +15,11 @@ import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subj
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../model/account_data.dart'; import '../session/session.dart';
import '../session/session_manager.dart';
import '../state/app/modules/timetable/data_provider/timetable_data_provider.dart';
import '../state/app/modules/timetable/subject/timetable_subject.dart';
import '../widget_data/widget_data_mapper.dart'; import '../widget_data/widget_data_mapper.dart';
import '../widget_data/widget_publisher.dart'; import '../widget_data/widget_publisher.dart';
import '../widget_data/widget_sync.dart'; import '../widget_data/widget_sync.dart';
@@ -81,17 +82,17 @@ class WidgetBackgroundTask {
/// Throws on fetch failure so the worker path can signal a retry. /// Throws on fetch failure so the worker path can signal a retry.
static Future<void> runRefreshNow({bool force = false}) async { static Future<void> runRefreshNow({bool force = false}) async {
await WidgetSync.ensureInitialized(); await WidgetSync.ensureInitialized();
bool populated; Session? session;
try { try {
// Bounded: a hanging keystore read must not stall the caller's budget // Bounded: a hanging keystore read must not stall the caller's budget
// (FCM handler ~25s on iOS) forever. // (FCM handler ~25s on iOS) forever.
populated = await AccountData().waitForPopulation().timeout( session = await SessionManager().waitForLoad().timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
); );
} on TimeoutException { } on TimeoutException {
populated = false; session = null;
} }
if (!populated) { if (session == null) {
// Deliberately does NOT flip the widget to logged-out: a failed or slow // 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 // keychain read (locked iOS device during the 06:00 silent push) is
// indistinguishable from "never logged in" here, and blanking the // indistinguishable from "never logged in" here, and blanking the
@@ -101,11 +102,23 @@ class WidgetBackgroundTask {
return; return;
} }
final fetchedAt = await WidgetSync.getFetchedAt(); final fetchedAt = await WidgetSync.getFetchedAt();
if (shouldSkipRefresh(fetchedAt: fetchedAt, now: DateTime.now(), force: force)) { if (shouldSkipRefresh(
fetchedAt: fetchedAt,
now: DateTime.now(),
force: force,
)) {
log('[widget-refresh] snapshot is fresh, skipping refresh'); log('[widget-refresh] snapshot is fresh, skipping refresh');
return; return;
} }
await _refresh(); final subject = widgetRefreshSubject(
session: session,
stored: await WidgetSync.getSubject(),
);
if (subject == null) {
log('[widget-refresh] no plan selected yet, skipping refresh');
return;
}
await _refresh(subject);
} }
static Future<void> cancelAll() async { static Future<void> cancelAll() async {
@@ -126,6 +139,16 @@ bool shouldSkipRefresh({
return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce; return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce;
} }
/// The plan the background refresh loads. Guardians only get one once the app
/// has published a child; before that there is nothing sensible to fetch.
TimetableSubject? widgetRefreshSubject({
required Session session,
required TimetableSubject? stored,
}) => switch (session) {
CredentialSession() => const OwnTimetable(),
GuardianSession() => stored is ChildTimetable ? stored : null,
};
@pragma('vm:entry-point') @pragma('vm:entry-point')
void _callbackDispatcher() { void _callbackDispatcher() {
Workmanager().executeTask((task, inputData) async { Workmanager().executeTask((task, inputData) async {
@@ -142,7 +165,7 @@ void _callbackDispatcher() {
}); });
} }
Future<void> _refresh() async { Future<void> _refresh(TimetableSubject subject) async {
await WidgetSync.ensureInitialized(); await WidgetSync.ensureInitialized();
// The background isolate doesn't go through main.dart's BlocBuilder, so we // The background isolate doesn't go through main.dart's BlocBuilder, so we
// re-apply the endpoint the foreground last persisted. Without this the // re-apply the endpoint the foreground last persisted. Without this the
@@ -165,9 +188,11 @@ Future<void> _refresh() async {
// latency is the slowest request, not the sum (matters for the push path's // 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 // hard time budget). Reference-data failures fall through to null in the
// mapper rather than aborting the whole refresh. // mapper rather than aborting the whole refresh.
final timetableFuture = TimetableGetWeek().run( final until = weekEndExclusive.subtract(const Duration(days: 1));
final timetableFuture = TimetableDataProvider.fetchWeek(
subject,
from: weekStart, from: weekStart,
until: weekEndExclusive.subtract(const Duration(days: 1)), until: until,
); );
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>( final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
() => TimetableGetSubjects().run(), () => TimetableGetSubjects().run(),
@@ -181,11 +206,11 @@ Future<void> _refresh() async {
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>( final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
() => TimetableGetTimegrid().run(), () => TimetableGetTimegrid().run(),
); );
final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>( final customEventsFuture = subject.supportsCustomEvents
() => GetCustomTimetableEvent( ? _runOrNull<GetCustomTimetableEventResponse>(
GetCustomTimetableEventParams(AccountData().getUserSecret()), () => TimetableCustomEventsGet().run(),
).run(), )
); : Future<GetCustomTimetableEventResponse?>.value();
final timetable = await timetableFuture; final timetable = await timetableFuture;
final subjects = await subjectsFuture; final subjects = await subjectsFuture;
final rooms = await roomsFuture; final rooms = await roomsFuture;
@@ -195,9 +220,9 @@ Future<void> _refresh() async {
final lessons = timetable.entries; final lessons = timetable.entries;
final [connectDouble, isTeacher] = await Future.wait([ final [connectDouble, showClassInsteadOfTeacher] = await Future.wait([
WidgetSync.getConnectDoubleLessons(), WidgetSync.getConnectDoubleLessons(),
WidgetSync.getIsTeacher(), WidgetSync.getShowClassInsteadOfTeacher(),
]); ]);
final dayData = WidgetDataMapper.buildDayData( final dayData = WidgetDataMapper.buildDayData(
now: now, now: now,
@@ -208,7 +233,7 @@ Future<void> _refresh() async {
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
connectDoubleLessons: connectDouble, connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher, showClassInsteadOfTeacher: showClassInsteadOfTeacher,
); );
final weekData = WidgetDataMapper.buildWeekData( final weekData = WidgetDataMapper.buildWeekData(
now: now, now: now,
@@ -219,9 +244,15 @@ Future<void> _refresh() async {
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
connectDoubleLessons: connectDouble, connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher, showClassInsteadOfTeacher: showClassInsteadOfTeacher,
); );
// The user may have switched the child while the requests ran; writing now
// would show the previous child's plan.
if (await WidgetSync.getSubject() != subject) {
log('[widget-bg] subject changed during refresh, discarding');
return;
}
await WidgetSync.writeDayData(dayData); await WidgetSync.writeDayData(dayData);
await WidgetSync.writeWeekData(weekData); await WidgetSync.writeWeekData(weekData);
await WidgetSync.setLoggedIn(true); await WidgetSync.setLoggedIn(true);
+88 -56
View File
@@ -24,15 +24,16 @@ import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart'; import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart';
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'app.dart'; import 'app.dart';
import 'auth_link/guardian_link_listener.dart';
import 'background/widget_background_task.dart'; import 'background/widget_background_task.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'model/account_data.dart';
import 'notification/notification_service.dart'; import 'notification/notification_service.dart';
import 'push/push_message_handler.dart'; import 'push/push_message_handler.dart';
import 'push/push_registration.dart'; import 'push/push_registration.dart';
import 'push/push_registration_store.dart'; import 'push/push_registration_store.dart';
import 'push/push_renderer.dart'; import 'push/push_renderer.dart';
import 'routing/app_routes.dart'; import 'routing/app_routes.dart';
import 'session/session_manager.dart';
import 'share_intent/share_intent_listener.dart'; import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_bloc.dart';
import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/account/bloc/account_state.dart';
@@ -40,14 +41,16 @@ import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import 'state/app/modules/chat/bloc/chat_bloc.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/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/children/child_selection_cubit.dart';
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'state/app/modules/timetable/primary/primary_timetable_scope.dart';
import 'storage/hydrated_storage_bootstrap.dart'; import 'storage/hydrated_storage_bootstrap.dart';
import 'storage/settings.dart'; import 'storage/settings.dart';
import 'theming/dark_app_theme.dart'; import 'theming/dark_app_theme.dart';
import 'theming/light_app_theme.dart'; import 'theming/light_app_theme.dart';
import 'utils/app_paths.dart'; import 'utils/app_paths.dart';
import 'utils/debouncer.dart';
import 'utils/downloads/download_manager.dart'; import 'utils/downloads/download_manager.dart';
import 'view/login/account_loading_screen.dart'; import 'view/login/account_loading_screen.dart';
import 'view/login/login.dart'; import 'view/login/login.dart';
@@ -149,17 +152,18 @@ Future<void> main() async {
_startupStep('documents dir', () async { _startupStep('documents dir', () async {
AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path; AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path;
}), }),
// The keychain may still be locked right after device unlock; AccountData // The keychain may still be locked right after device unlock; the session
// keeps retrying, so on timeout the app starts on the loading screen and // keeps retrying, so on timeout the app starts on the loading screen and
// flips to the real state once the session is readable (see _MainState). // flips to the real state once the session is readable (see _MainState).
_startupStep( _startupStep(
'account data', 'account data',
AccountData().waitForPopulation, SessionManager().waitForLoad,
timeout: const Duration(seconds: 5), timeout: const Duration(seconds: 5),
// Expected on every background wake of a locked device; not an error. // Expected on every background wake of a locked device; not an error.
report: false, report: false,
), ),
_startupStep('share intent', ShareIntentListener.instance.initialize), _startupStep('share intent', ShareIntentListener.instance.initialize),
_startupStep('guardian link', GuardianLinkListener.instance.initialize),
]; ];
log('starting app initialisation...'); log('starting app initialisation...');
@@ -215,7 +219,7 @@ Future<void> main() async {
// has data ready by the time the user navigates to it. No-op when a // has data ready by the time the user navigates to it. No-op when a
// cached payload is already present, so this does not undo the day-long // cached payload is already present, so this does not undo the day-long
// root cache TTL. // root cache TTL.
if (AccountData().isPopulated()) { if (SessionManager().hasNextcloud) {
unawaited( unawaited(
ListFilesCache.prefetchRootListing().onError( ListFilesCache.prefetchRootListing().onError(
(e, _) => log('Files root prefetch failed: $e'), (e, _) => log('Files root prefetch failed: $e'),
@@ -228,11 +232,17 @@ Future<void> main() async {
// placeholder flash. // placeholder flash.
AvatarDiskCache.instance.warmUp(); AvatarDiskCache.instance.warmUp();
// Created eagerly so the endpoint is configured before anything below can
// issue a request (the primary timetable bloc loads on creation).
final settingsCubit = SettingsCubit();
_syncMarianumConnectEndpoint(settingsCubit.state);
settingsCubit.stream.listen(_syncMarianumConnectEndpoint);
log('running app...'); log('running app...');
runApp( runApp(
MultiBlocProvider( MultiBlocProvider(
providers: [ providers: [
BlocProvider<SettingsCubit>(create: (_) => SettingsCubit()), BlocProvider<SettingsCubit>.value(value: settingsCubit),
BlocProvider<AccountBloc>( BlocProvider<AccountBloc>(
create: (_) => AccountBloc(initialStatus: _initialAccountStatus()), create: (_) => AccountBloc(initialStatus: _initialAccountStatus()),
), ),
@@ -245,17 +255,31 @@ Future<void> main() async {
BlocProvider<ChatBloc>( BlocProvider<ChatBloc>(
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()), create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
), ),
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()), BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()),
], ],
child: const Main(), child: const PrimaryTimetableScope(child: Main()),
), ),
); );
} }
String? _syncedMcBaseUrl;
/// Keeps the MC dio singleton aligned with the selected endpoint (live /
/// beta / custom), mirrored into WidgetSync so the background isolate
/// refreshes against the same endpoint. Settings emit on every toggle; only
/// an actual URL change is applied.
void _syncMarianumConnectEndpoint(Settings settings) {
final url = settings.devToolsSettings.resolveMarianumConnectBaseUrl();
if (url == _syncedMcBaseUrl) return;
_syncedMcBaseUrl = url;
MarianumConnectEndpoint.update(url);
unawaited(WidgetSync.setMarianumConnectBaseUrl(url));
}
AccountStatus _initialAccountStatus() { AccountStatus _initialAccountStatus() {
final account = AccountData(); final session = SessionManager();
if (account.isPopulated()) return AccountStatus.loggedIn; if (session.isSignedIn) return AccountStatus.loggedIn;
return account.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; return session.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined;
} }
class Main extends StatefulWidget { class Main extends StatefulWidget {
@@ -278,36 +302,45 @@ class _MainState extends State<Main> {
super.initState(); super.initState();
Jiffy.setLocale('de'); Jiffy.setLocale('de');
AccountData().waitForPopulation().then((value) { SessionManager().unauthorizedSignal.addListener(_onUnauthorized);
SessionManager().waitForLoad().then((session) {
if (!mounted) return; if (!mounted) return;
final accountBloc = context.read<AccountBloc>(); final accountBloc = context.read<AccountBloc>();
accountBloc.setStatus( accountBloc.setStatus(
value ? AccountStatus.loggedIn : AccountStatus.loggedOut, session != null ? AccountStatus.loggedIn : AccountStatus.loggedOut,
); );
if (value) { if (session != null) {
_scheduleSessionValidation(accountBloc); _scheduleSessionValidation(accountBloc);
// Cold start while already logged in: the account status doesn't // Cold start while already logged in: the account status doesn't
// change, so the loggedIn listener below never fires — refresh // change, so the loggedIn listener below never fires.
// capabilities here, then self-heal the push registration. _onSessionActive();
final settingsCubit = context.read<SettingsCubit>();
unawaited(
context.read<CapabilitiesCubit>().load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, context.read<CapabilitiesCubit>());
}),
);
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
} }
}); });
} }
/// Warms the core caches (timetable, chat list, files root) in the /// Pulls the capability flags of the active account, then registers push
/// background so the first screen render hits populated data. /// right away instead of deferring it to the next app start.
void _onSessionActive() {
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, capabilitiesCubit);
}),
);
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
}
/// Warms the chat list and files root in the background so the first screen
/// render hits populated data. The timetable needs no warm-up:
/// PrimaryTimetableScope creates a freshly loading bloc per account.
void _prefetchBaseData(BuildContext context) { void _prefetchBaseData(BuildContext context) {
context.read<TimetableBloc>().refresh();
unawaited(context.read<ChatListBloc>().refresh(silent: true)); unawaited(context.read<ChatListBloc>().refresh(silent: true));
if (SessionManager().hasNextcloud) {
unawaited(ListFilesCache.prefetchRootListing()); unawaited(ListFilesCache.prefetchRootListing());
} }
}
/// Registers/self-heals the push subscription whenever the backend advertises /// Registers/self-heals the push subscription whenever the backend advertises
/// the capability — independent of the notification toggle, so a user with /// the capability — independent of the notification toggle, so a user with
@@ -329,6 +362,23 @@ class _MainState extends State<Main> {
); );
} }
@override
void dispose() {
SessionManager().unauthorizedSignal.removeListener(_onUnauthorized);
super.dispose();
}
void _onUnauthorized() {
if (!mounted) return;
final accountBloc = context.read<AccountBloc>();
if (accountBloc.state.status != AccountStatus.loggedIn) return;
Debouncer.throttle(
'sessionUnauthorized',
const Duration(seconds: 30),
() => _scheduleSessionValidation(accountBloc),
);
}
/// Background credential check: a 401 means the password was rotated /// Background credential check: a 401 means the password was rotated
/// server-side, so the validator wipes the local session and flips the /// server-side, so the validator wipes the local session and flips the
/// account bloc to `loggedOut` (sending the user to the login screen). /// account bloc to `loggedOut` (sending the user to the login screen).
@@ -349,14 +399,6 @@ class _MainState extends State<Main> {
child: BlocBuilder<SettingsCubit, Settings>( child: BlocBuilder<SettingsCubit, Settings>(
builder: (context, settings) { builder: (context, settings) {
final devToolsSettings = settings.devToolsSettings; final devToolsSettings = settings.devToolsSettings;
// Keep the MC dio singleton aligned with the currently selected
// endpoint (live / beta / custom). Idempotent when the URL is
// unchanged so it's safe to call on every rebuild. Mirrored into
// WidgetSync so the background isolate refreshes against the same
// endpoint.
final mcBaseUrl = devToolsSettings.resolveMarianumConnectBaseUrl();
MarianumConnectEndpoint.update(mcBaseUrl);
unawaited(WidgetSync.setMarianumConnectBaseUrl(mcBaseUrl));
// Mirror the notification toggle into group-scoped storage so the FCM // Mirror the notification toggle into group-scoped storage so the FCM
// background isolate and the iOS NSE can suppress rendering when off. // background isolate and the iOS NSE can suppress rendering when off.
unawaited( unawaited(
@@ -409,22 +451,8 @@ class _MainState extends State<Main> {
listenWhen: (previous, current) => listenWhen: (previous, current) =>
previous.status != current.status, previous.status != current.status,
listener: (context, accountState) { listener: (context, accountState) {
// Fresh login (loggedOut -> loggedIn): pull capability flags
// for the newly authenticated user, then register push right
// away instead of deferring it to the next app start.
if (accountState.status == AccountStatus.loggedIn) { if (accountState.status == AccountStatus.loggedIn) {
final settingsCubit = context.read<SettingsCubit>(); _onSessionActive();
final capabilitiesCubit = context
.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, capabilitiesCubit);
}),
);
unawaited(
context.read<NextcloudCapabilitiesCubit>().load(),
);
_showPostLoginSplash = true; _showPostLoginSplash = true;
_appMounted = false; _appMounted = false;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -449,11 +477,12 @@ class _MainState extends State<Main> {
// — by the time it runs the dialog/Settings context is // — by the time it runs the dialog/Settings context is
// gone but this listener context is still valid. // gone but this listener context is still valid.
final settingsCubit = context.read<SettingsCubit>(); final settingsCubit = context.read<SettingsCubit>();
final timetableBloc = context.read<TimetableBloc>();
final chatListBloc = context.read<ChatListBloc>();
final chatBloc = context.read<ChatBloc>();
final breakerBloc = context.read<BreakerBloc>(); final breakerBloc = context.read<BreakerBloc>();
final capabilitiesCubit = context.read<CapabilitiesCubit>(); final capabilitiesCubit = context.read<CapabilitiesCubit>();
final childSelectionCubit = context
.read<ChildSelectionCubit>();
final chatListBloc = context.read<ChatListBloc>();
final chatBloc = context.read<ChatBloc>();
final nextcloudCapabilitiesCubit = context final nextcloudCapabilitiesCubit = context
.read<NextcloudCapabilitiesCubit>(); .read<NextcloudCapabilitiesCubit>();
// Defer the actual wipe until after this frame so the // Defer the actual wipe until after this frame so the
@@ -464,7 +493,7 @@ class _MainState extends State<Main> {
unawaited( unawaited(
_wipeUserState( _wipeUserState(
settingsCubit: settingsCubit, settingsCubit: settingsCubit,
timetableBloc: timetableBloc, childSelectionCubit: childSelectionCubit,
chatListBloc: chatListBloc, chatListBloc: chatListBloc,
chatBloc: chatBloc, chatBloc: chatBloc,
breakerBloc: breakerBloc, breakerBloc: breakerBloc,
@@ -510,7 +539,7 @@ class _MainState extends State<Main> {
Future<void> _wipeUserState({ Future<void> _wipeUserState({
required SettingsCubit settingsCubit, required SettingsCubit settingsCubit,
required TimetableBloc timetableBloc, required ChildSelectionCubit childSelectionCubit,
required ChatListBloc chatListBloc, required ChatListBloc chatListBloc,
required ChatBloc chatBloc, required ChatBloc chatBloc,
required BreakerBloc breakerBloc, required BreakerBloc breakerBloc,
@@ -523,8 +552,11 @@ Future<void> _wipeUserState({
// wraps MaterialApp, so emit'ing a fresh state would tear down the // wraps MaterialApp, so emit'ing a fresh state would tear down the
// freshly-mounted Login tree and leave the user with a blank screen // freshly-mounted Login tree and leave the user with a blank screen
// (the MaterialApp.builder backdrop) until the next interaction. // (the MaterialApp.builder backdrop) until the next interaction.
// The timetable bloc is not reset here: PrimaryTimetableScope replaces it
// on the status change, and HydratedBloc.storage.clear() below drops the
// cached plans of every subject.
await Future.wait([ await Future.wait([
timetableBloc.reset(), childSelectionCubit.reset(),
chatListBloc.reset(), chatListBloc.reset(),
chatBloc.reset(), chatBloc.reset(),
breakerBloc.reset(), breakerBloc.reset(),
-333
View File
@@ -1,333 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../push/push_secure_storage.dart';
import '../utils/exponential_backoff.dart';
class AccountData {
static const _usernameField = 'username';
static const _passwordField = 'password';
// App passwords live in the push-shared (group-scoped) keystore so the iOS
// Notification Service Extension can authenticate Nextcloud calls too.
// The talk password authenticates the second (apptype=talk) push
// registration — Nextcloud binds each push subscription to its session
// 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.
static const _demoPasswordPlaceholder = 'demo';
// `first_unlock` so a background launch on a locked device (silent push,
// BGAppRefresh) can still read the session. Items written by older versions
// carry the plugin default `unlocked` and are invisible to this instance
// until _migrateKeychainAccessibility moved them over.
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked),
);
static const List<String> _sessionFields = [
_usernameField,
_passwordField,
_demoField,
_loginFlowField,
];
static final AccountData _instance = AccountData._construct();
Completer<void> _populated = Completer();
factory AccountData() => _instance;
AccountData._construct() {
unawaited(_loadWithRetry());
}
String? _username;
String? _password;
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!;
}
String getPassword() {
if (_password == null) throw Exception('Password not initialized');
return _password!;
}
String getUserSecret() => sha512
.convert(utf8.encode('${getUsername()}:${getPassword()}'))
.toString();
Future<void> setData(String username, String password) async {
await _secureStorage.write(key: _usernameField, value: username);
await _secureStorage.write(key: _passwordField, value: password);
_username = username;
_password = password;
if (!_populated.isCompleted) _populated.complete();
}
/// Enters a local demo session for [username] — no real credentials or token;
/// every backend is served from fixtures while [isDemo] is true (see DemoMode).
Future<void> setDemo(String username) async {
await _secureStorage.write(key: _usernameField, value: username);
await _secureStorage.write(
key: _passwordField,
value: _demoPasswordPlaceholder,
);
await _secureStorage.write(key: _demoField, value: 'true');
_username = username;
_password = _demoPasswordPlaceholder;
_isDemo = true;
if (!_populated.isCompleted) _populated.complete();
}
Future<void> removeData() async {
_populated = Completer();
_username = null;
_password = null;
_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();
}
/// Persists a freshly minted Nextcloud app password. After this every
/// [getBasicAuthHeader] call authenticates with the app password instead of
/// the real password.
Future<void> setAppPassword(String appPassword) async {
_appPassword = appPassword;
try {
await pushSecureStorage.write(key: _appPasswordField, value: appPassword);
} on Object {
// Group-scoped keystore may be unavailable (e.g. iOS entitlement not yet
// provisioned). Keeping it in memory still lets this session use it.
}
}
Future<void> clearAppPassword() async {
_appPassword = null;
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.
Future<void> setAppPasswordTalk(String appPassword) async {
_appPasswordTalk = appPassword;
try {
await pushSecureStorage.write(
key: _appPasswordTalkField,
value: appPassword,
);
} on Object {
// Group-scoped keystore may be unavailable — in-memory still works for
// this session, matching setAppPassword.
}
}
Future<void> clearAppPasswordTalk() async {
_appPasswordTalk = null;
await _clearAppPasswordTalkStorage();
}
bool hasAppPasswordTalk() =>
_appPasswordTalk != null && _appPasswordTalk!.isNotEmpty;
Future<void> _clearAppPasswordStorage() async {
try {
await pushSecureStorage.delete(key: _appPasswordField);
} on Object {
// ignore — nothing stored or keystore unavailable
}
}
Future<void> _clearAppPasswordTalkStorage() async {
try {
await pushSecureStorage.delete(key: _appPasswordTalkField);
} on Object {
// ignore — nothing stored or keystore unavailable
}
}
/// iOS keychain reads fail while protected data is unavailable (app launch
/// racing the unlock, background wake on a locked device). Without a retry
/// the completer never resolved and the app stayed on the launch screen.
Future<void> _loadWithRetry() async {
for (var attempt = 1; !_populated.isCompleted; attempt++) {
try {
await _migrateAndLoad();
return;
} catch (e, s) {
log('AccountData load failed (attempt $attempt): $e', stackTrace: s);
await Future<void>.delayed(exponentialBackoff(attempt));
}
}
}
/// Stops waiting for the stored session; the app then behaves as logged
/// out. The keychain entries stay untouched so a later start can still
/// restore the session.
void abandonLoad() {
if (!_populated.isCompleted) _populated.complete();
}
Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage();
await _migrateKeychainAccessibility();
_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(
key: _appPasswordTalkField,
);
} on Object {
_appPassword = null;
_appPasswordTalk = null;
}
if (!_populated.isCompleted) _populated.complete();
}
// Move credentials from the old SharedPreferences plain-text storage into the
// platform's secure keystore. Run once per install and clear the legacy keys.
Future<void> _migrateFromLegacyStorage() async {
final prefs = await SharedPreferences.getInstance();
final legacyUsername = prefs.getString(_usernameField);
final legacyPassword = prefs.getString(_passwordField);
if (legacyUsername == null || legacyPassword == null) return;
final hasSecure = (await _secureStorage.read(key: _usernameField)) != null;
if (!hasSecure) {
await _secureStorage.write(key: _usernameField, value: legacyUsername);
await _secureStorage.write(key: _passwordField, value: legacyPassword);
}
await prefs.remove(_usernameField);
await prefs.remove(_passwordField);
}
Future<void> _migrateKeychainAccessibility() async {
if (!Platform.isIOS) return;
for (final field in _sessionFields) {
final value = await _legacySecureStorage.read(key: field);
if (value == null) continue;
// Same account+service: the legacy item has to go before the re-add.
await _legacySecureStorage.delete(key: field);
await _secureStorage.write(key: field, value: value);
}
}
Future<bool> waitForPopulation() async {
await _populated.future;
return isPopulated();
}
/// True once the stored session has been read (or given up on).
bool get isLoaded => _populated.isCompleted;
bool isPopulated() => _username != null && _password != null;
/// Returns the value for an HTTP `Authorization` header using HTTP Basic.
/// Prefer this over embedding credentials in URLs — error logs and crash
/// reports often capture the URL but not headers.
String getBasicAuthHeader() {
_requirePopulated();
// Prefer the scoped app password once available; it survives real-password
// rotation and is what the push-v2 registration is bound to.
return _basicAuth(_appPassword ?? _password!);
}
/// Basic-auth header using the Talk app password — authenticates the
/// apptype=talk push registration (and its unregister). Throws when the
/// talk password has not been minted yet; callers treat that as a failed
/// talk registration and retry on the next start.
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!);
}
/// Basic-auth header that always uses the real password. Needed exactly once,
/// to mint the app password via `core/getapppassword` (an app password cannot
/// mint another).
String getRealPasswordBasicAuthHeader() {
_requirePopulated();
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(
'AccountData (e.g. username or password) is not initialized!',
);
}
}
String _basicAuth(String secret) =>
'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
/// Convenience wrapper around [getBasicAuthHeader] returning a single-entry
/// header map ready to merge into HTTP request headers.
Map<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()};
}
+5
View File
@@ -8,6 +8,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/chat_thread_store.dart'; import '../push/chat_thread_store.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import '../session/session_manager.dart';
import '../state/app/modules/app_modules.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'notification_service.dart'; import 'notification_service.dart';
@@ -76,6 +78,9 @@ class NotificationTasks {
/// the matching chat to be opened automatically once the chat list view /// the matching chat to be opened automatically once the chat list view
/// resolves the token (handled inside [ChatList]). /// resolves the token (handled inside [ChatList]).
static void navigateToTalk(BuildContext context, {String? chatToken}) { static void navigateToTalk(BuildContext context, {String? chatToken}) {
if (!AppModule.isAvailableFor(Modules.talk, SessionManager().current)) {
return;
}
if (chatToken != null && chatToken.isNotEmpty) { if (chatToken != null && chatToken.isNotEmpty) {
AppRoutes.openChatByToken(context, chatToken); AppRoutes.openChatByToken(context, chatToken);
} else { } else {
+71
View File
@@ -0,0 +1,71 @@
import 'dart:developer';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
import '../utils/random_id.dart';
import 'push_device_info.dart';
import 'push_secure_storage.dart';
/// Push registration for accounts without Nextcloud (guardians): the device
/// registers straight with MarianumConnect and only receives its direct
/// pushes (newsletter, widget refresh, later guardian messages). Nextcloud
/// normally supplies the device identifier; here a random one is kept per
/// install.
class DirectPushRegistration {
static const String registrationType = 'direct';
static const _deviceIdentifierKey = 'push_direct_device_identifier';
final FlutterSecureStorageLike _storage;
const DirectPushRegistration({
FlutterSecureStorageLike storage = const PushSecureStorage(),
}) : _storage = storage;
Future<bool> register() async {
try {
final (fcmToken, appVersion, identifier) = await (
FirebaseMessaging.instance.getToken(),
pushAppVersion(),
deviceIdentifier(),
).wait;
if (fcmToken == null || fcmToken.isEmpty) {
log('Push (direct): no FCM token, skipping registration');
return false;
}
await PushDeviceRegister().run(
deviceIdentifier: identifier,
pushToken: fcmToken,
platform: pushPlatform,
registrationType: registrationType,
appVersion: appVersion,
);
return true;
} on Object catch (e) {
log('Push (direct): registration failed: $e');
return false;
}
}
Future<void> unregister() async {
final identifier = await _storage.read(key: _deviceIdentifierKey);
if (identifier == null) return;
try {
await PushDeviceUnregister().run(deviceIdentifier: identifier);
} on Object catch (e) {
log('Push (direct): unregister failed: $e');
}
await _storage.delete(key: _deviceIdentifierKey);
}
/// Stable per install until [unregister], so re-registrations upsert the
/// same server row instead of piling up devices.
Future<String> deviceIdentifier() async {
final stored = await _storage.read(key: _deviceIdentifierKey);
if (stored != null && stored.isNotEmpty) return stored;
final fresh = randomHexId();
await _storage.write(key: _deviceIdentifierKey, value: fresh);
return fresh;
}
}
+11 -7
View File
@@ -8,8 +8,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../api/marianumcloud/nextcloud_ocs.dart'; import '../api/marianumcloud/nextcloud_ocs.dart';
import '../model/account_data.dart';
import '../notification/notification_service.dart'; import '../notification/notification_service.dart';
import '../session/session_manager.dart';
import 'chat_thread_store.dart'; import 'chat_thread_store.dart';
import 'nid_store.dart'; import 'nid_store.dart';
import 'push_renderer.dart'; import 'push_renderer.dart';
@@ -38,7 +38,7 @@ void _plog(String message) {
/// Handles Talk notification actions (inline reply, mark-as-read). Runs in the /// Handles Talk notification actions (inline reply, mark-as-read). Runs in the
/// background isolate spawned by flutter_local_notifications, so it may not /// background isolate spawned by flutter_local_notifications, so it may not
/// share any app state — it reads credentials straight from secure storage via /// share any app state — it reads credentials straight from secure storage via
/// the [AccountData] singleton after awaiting population. /// the [SessionManager] singleton after awaiting the stored session.
/// ///
/// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one /// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one
/// on [handleBackgroundResponse]: the callback is resolved via /// on [handleBackgroundResponse]: the callback is resolved via
@@ -56,7 +56,7 @@ class PushActions {
) async { ) async {
// The FLN action isolate starts WITHOUT main(): unlike the FCM background // The FLN action isolate starts WITHOUT main(): unlike the FCM background
// isolate, plugins are not registered automatically there. Without this, // isolate, plugins are not registered automatically there. Without this,
// AccountData's secure-storage/prefs reads throw or never complete → no // The session's secure-storage/prefs reads throw or never complete → no
// auth header, the Talk POST never happens and the RemoteInput spinner // auth header, the Talk POST never happens and the RemoteInput spinner
// runs forever. // runs forever.
DartPluginRegistrant.ensureInitialized(); DartPluginRegistrant.ensureInitialized();
@@ -125,7 +125,8 @@ class PushActions {
/// any) followed by the technical reason. /// any) followed by the technical reason.
static String actionFailureBody({String? lostText, required String detail}) { static String actionFailureBody({String? lostText, required String detail}) {
return [ return [
if (lostText != null && lostText.isNotEmpty) 'Deine Nachricht: „$lostText', if (lostText != null && lostText.isNotEmpty)
'Deine Nachricht: „$lostText',
'Grund: $detail', 'Grund: $detail',
].join('\n'); ].join('\n');
} }
@@ -188,7 +189,10 @@ class PushActions {
static Future<({bool ok, String detail})> sendReply( static Future<({bool ok, String detail})> sendReply(
String chatToken, String chatToken,
String message, String message,
) => _ocsPost('apps/spreed/api/v1/chat/$chatToken', body: {'message': message}); ) => _ocsPost(
'apps/spreed/api/v1/chat/$chatToken',
body: {'message': message},
);
static Future<({bool ok, String detail})> markRead(String chatToken) => static Future<({bool ok, String detail})> markRead(String chatToken) =>
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read'); _ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
@@ -200,10 +204,10 @@ class PushActions {
try { try {
// Bounded: a hanging population (e.g. keystore issue) must fail the // Bounded: a hanging population (e.g. keystore issue) must fail the
// action instead of leaving the notification spinner running forever. // action instead of leaving the notification spinner running forever.
final populated = await AccountData().waitForPopulation().timeout( final session = await SessionManager().waitForLoad().timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
); );
if (!populated) { if (session?.nextcloud == null) {
_plog('Push action $path aborted: credentials unreadable in isolate'); _plog('Push action $path aborted: credentials unreadable in isolate');
return ( return (
ok: false, ok: false,
+15
View File
@@ -0,0 +1,15 @@
import 'dart:io';
import 'package:package_info_plus/package_info_plus.dart';
/// Platform value MarianumConnect expects in push registrations.
String get pushPlatform => Platform.isIOS ? 'ios' : 'android';
/// App version sent along with push registrations; null when unavailable.
Future<String?> pushAppVersion() async {
try {
return (await PackageInfo.fromPlatform()).version;
} on Object {
return null;
}
}
+41 -29
View File
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:nextcloud/notifications.dart' show generatePushTokenHash; import 'package:nextcloud/notifications.dart' show generatePushTokenHash;
import 'package:package_info_plus/package_info_plus.dart';
import '../api/demo/demo_mode.dart'; import '../api/demo/demo_mode.dart';
import '../api/marianumcloud/app_password/delete_app_password.dart'; import '../api/marianumcloud/app_password/delete_app_password.dart';
@@ -11,9 +10,12 @@ import '../api/marianumcloud/app_password/get_app_password.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart'; import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart'; import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
import '../model/account_data.dart';
import '../model/endpoint_data.dart'; import '../model/endpoint_data.dart';
import '../session/nextcloud_credentials.dart';
import '../session/session_manager.dart';
import 'direct_push_registration.dart';
import 'nextcloud_push_api.dart'; import 'nextcloud_push_api.dart';
import 'push_device_info.dart';
import 'push_keypair.dart'; import 'push_keypair.dart';
import 'push_registration_store.dart'; import 'push_registration_store.dart';
import 'push_registration_type.dart'; import 'push_registration_type.dart';
@@ -48,8 +50,6 @@ class PushRegistration {
_store = store ?? const PushRegistrationStore(), _store = store ?? const PushRegistrationStore(),
_nextcloud = nextcloud ?? NextcloudPushApi(); _nextcloud = nextcloud ?? NextcloudPushApi();
String get _platform => Platform.isIOS ? 'ios' : 'android';
String get _talkUserAgent => String get _talkUserAgent =>
Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid; Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;
@@ -63,20 +63,29 @@ class PushRegistration {
/// slash) — persisted alongside the registration to detect endpoint changes. /// slash) — persisted alongside the registration to detect endpoint changes.
String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}'; String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
NextcloudCredentials? get _nextcloudOrNull =>
SessionManager().current?.nextcloud;
/// Channel for sessions without Nextcloud; see [DirectPushRegistration].
static const _direct = DirectPushRegistration();
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push /// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
/// registration binds to it, so it must be obtained before registering. /// registration binds to it, so it must be obtained before registering.
Future<void> ensureAppPassword() async { Future<void> ensureAppPassword() async {
if (AccountData().hasAppPassword()) return; final nextcloud = _nextcloudOrNull;
if (AccountData().usesLoginFlow) { if (nextcloud == null || nextcloud.hasAppPassword) return;
if (nextcloud.usesLoginFlow) {
// Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt, // Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt,
// stilles Minting ist unmöglich. Reparatur nur interaktiv über // stilles Minting ist unmöglich. Reparatur nur interaktiv über
// Einstellungen → „Nextcloud neu verbinden". // Einstellungen → „Nextcloud neu verbinden".
log('Push: login-flow account without app password, cannot mint silently'); log(
'Push: login-flow account without app password, cannot mint silently',
);
return; return;
} }
try { try {
final appPassword = await GetAppPassword().run(); final appPassword = await GetAppPassword().run();
await AccountData().setAppPassword(appPassword); await SessionManager().setAppPassword(appPassword);
} on Object catch (e) { } on Object catch (e) {
log('Push: could not obtain app password (non-blocking): $e'); log('Push: could not obtain app password (non-blocking): $e');
} }
@@ -85,15 +94,16 @@ class PushRegistration {
/// Ensures the second app password backing the Talk registration exists /// Ensures the second app password backing the Talk registration exists
/// (each `getapppassword` call with the real password mints a fresh one). /// (each `getapppassword` call with the real password mints a fresh one).
Future<void> ensureTalkAppPassword() async { Future<void> ensureTalkAppPassword() async {
if (AccountData().hasAppPasswordTalk()) return; final nextcloud = _nextcloudOrNull;
if (nextcloud == null || nextcloud.hasAppPasswordTalk) return;
// Flow-Konten können still kein zweites App-Passwort münzen — das // Flow-Konten können still kein zweites App-Passwort münzen — das
// Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin // Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin
// teilt sich die Talk-Registrierung das eine App-Passwort (siehe // teilt sich die Talk-Registrierung das eine App-Passwort (siehe
// AccountData.getTalkBasicAuthHeader). // NextcloudCredentials.talkBasicAuthHeader).
if (AccountData().usesLoginFlow) return; if (nextcloud.usesLoginFlow) return;
try { try {
final appPassword = await GetAppPassword().run(); final appPassword = await GetAppPassword().run();
await AccountData().setAppPasswordTalk(appPassword); await SessionManager().setAppPasswordTalk(appPassword);
} on Object catch (e) { } on Object catch (e) {
log('Push: could not obtain talk app password (non-blocking): $e'); log('Push: could not obtain talk app password (non-blocking): $e');
} }
@@ -107,6 +117,7 @@ class PushRegistration {
/// fire-and-forget (and simply ignore the result). /// fire-and-forget (and simply ignore the result).
Future<bool> register() async { Future<bool> register() async {
if (DemoMode.active) return false; if (DemoMode.active) return false;
if (_nextcloudOrNull == null) return _direct.register();
final String? fcmToken; final String? fcmToken;
try { try {
fcmToken = await FirebaseMessaging.instance.getToken(); fcmToken = await FirebaseMessaging.instance.getToken();
@@ -134,16 +145,13 @@ class PushRegistration {
return false; return false;
} }
String? appVersion; final appVersion = await pushAppVersion();
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on Object {
appVersion = null;
}
// Re-read: the ensure* calls above may have swapped the credentials.
final nextcloud = SessionManager().requireNextcloud();
final types = registrationTypesFor( final types = registrationTypesFor(
usesLoginFlow: AccountData().usesLoginFlow, usesLoginFlow: nextcloud.usesLoginFlow,
hasTalkAppPassword: AccountData().hasAppPasswordTalk(), hasTalkAppPassword: nextcloud.hasAppPasswordTalk,
); );
if (!types.contains(PushRegistrationType.general)) { if (!types.contains(PushRegistrationType.general)) {
await _recordAttempt( await _recordAttempt(
@@ -180,7 +188,7 @@ class PushRegistration {
devicePublicKeyPem: pems.publicKeyPem, devicePublicKeyPem: pems.publicKeyPem,
proxyServer: proxyServer, proxyServer: proxyServer,
authorizationHeader: isTalk authorizationHeader: isTalk
? AccountData().getTalkBasicAuthHeader() ? SessionManager().requireNextcloud().talkBasicAuthHeader
: null, : null,
userAgent: isTalk ? _talkUserAgent : null, userAgent: isTalk ? _talkUserAgent : null,
); );
@@ -199,7 +207,7 @@ class PushRegistration {
deviceIdentifierSignature: registration.signature, deviceIdentifierSignature: registration.signature,
userPublicKey: registration.publicKey, userPublicKey: registration.publicKey,
pushToken: fcmToken, pushToken: fcmToken,
platform: _platform, platform: pushPlatform,
registrationType: type.wireName, registrationType: type.wireName,
appVersion: appVersion, appVersion: appVersion,
); );
@@ -248,7 +256,7 @@ class PushRegistration {
try { try {
final endpoint = EndpointData().nextcloud(); final endpoint = EndpointData().nextcloud();
await _store.saveNativeAuthContext( await _store.saveNativeAuthContext(
username: AccountData().getUsername(), username: SessionManager().requireNextcloud().username,
baseUrl: 'https://${endpoint.full()}', baseUrl: 'https://${endpoint.full()}',
); );
} on Object catch (e) { } on Object catch (e) {
@@ -266,7 +274,7 @@ class PushRegistration {
// session token — each registration with its own app password. // session token — each registration with its own app password.
await _nextcloud.unregister( await _nextcloud.unregister(
authorizationHeader: type == PushRegistrationType.talk authorizationHeader: type == PushRegistrationType.talk
? AccountData().getTalkBasicAuthHeader() ? SessionManager().requireNextcloud().talkBasicAuthHeader
: null, : null,
); );
} on Object catch (e) { } on Object catch (e) {
@@ -405,7 +413,9 @@ class PushRegistration {
static Future<bool> syncSubscription({required bool capable}) async { static Future<bool> syncSubscription({required bool capable}) async {
if (!capable) return false; if (!capable) return false;
if (!await isOsPermissionGranted()) { if (!await isOsPermissionGranted()) {
log('Push: OS notification permission not granted, skipping registration'); log(
'Push: OS notification permission not granted, skipping registration',
);
return false; return false;
} }
final registration = PushRegistration(); final registration = PushRegistration();
@@ -429,6 +439,7 @@ class PushRegistration {
/// pushing before credentials are gone. /// pushing before credentials are gone.
Future<void> logoutCleanup() async { Future<void> logoutCleanup() async {
if (DemoMode.active) return; if (DemoMode.active) return;
if (_nextcloudOrNull == null) return _direct.unregister();
await unregister(); await unregister();
try { try {
await DeleteAppPassword().run(); await DeleteAppPassword().run();
@@ -436,15 +447,16 @@ class PushRegistration {
log('Push: delete app password failed: $e'); log('Push: delete app password failed: $e');
} }
try { try {
if (AccountData().hasAppPasswordTalk()) { final nextcloud = SessionManager().requireNextcloud();
if (nextcloud.hasAppPasswordTalk) {
await DeleteAppPassword().run( await DeleteAppPassword().run(
authorizationHeader: AccountData().getTalkBasicAuthHeader(), authorizationHeader: nextcloud.talkBasicAuthHeader,
); );
} }
} on Object catch (e) { } on Object catch (e) {
log('Push: delete talk app password failed: $e'); log('Push: delete talk app password failed: $e');
} }
await AccountData().clearAppPassword(); await SessionManager().clearAppPassword();
await AccountData().clearAppPasswordTalk(); await SessionManager().clearAppPasswordTalk();
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@ class PushRegistrationStore {
// (reply / mark-as-read) directly via URLSession while the Flutter engine is // (reply / mark-as-read) directly via URLSession while the Flutter engine is
// not guaranteed to run. It needs the Nextcloud username and base URL from the // not guaranteed to run. It needs the Nextcloud username and base URL from the
// shared (group-scoped) keychain; the app password already lives there // shared (group-scoped) keychain; the app password already lives there
// (AccountData writes `nextcloud_app_password` group-scoped). // (SessionManager writes `nextcloud_app_password` group-scoped).
static const _usernameKey = 'nextcloud_username'; static const _usernameKey = 'nextcloud_username';
static const _baseUrlKey = 'nextcloud_base_url'; static const _baseUrlKey = 'nextcloud_base_url';
// Mirror of the in-app notification toggle (`notificationSettings.enabled`), // Mirror of the in-app notification toggle (`notificationSettings.enabled`),
+1 -1
View File
@@ -24,7 +24,7 @@ const IOSOptions kPushIosOptions = IOSOptions(
); );
/// Shared secure storage instance for all push key material and registration /// Shared secure storage instance for all push key material and registration
/// bookkeeping. Kept separate from [AccountData]'s default storage because the /// bookkeeping. Kept separate from the session's default storage because the
/// entries here are group-scoped for NSE access. /// entries here are group-scoped for NSE access.
const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage( const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage(
iOptions: kPushIosOptions, iOptions: kPushIosOptions,
+4 -3
View File
@@ -1,7 +1,7 @@
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../model/account_data.dart'; import '../session/session_manager.dart';
import 'push_keypair.dart'; import 'push_keypair.dart';
import 'push_registration.dart'; import 'push_registration.dart';
import 'push_registration_store.dart'; import 'push_registration_store.dart';
@@ -125,14 +125,15 @@ Future<PushStatusReport> collectPushStatus({
lastRegistrationError: await store.lastRegistrationError(type), lastRegistrationError: await store.lastRegistrationError(type),
); );
final nextcloud = SessionManager().current?.nextcloud;
return PushStatusReport( return PushStatusReport(
settingEnabled: settingEnabled, settingEnabled: settingEnabled,
osPermission: await _osPermission(), osPermission: await _osPermission(),
serverCapability: !capabilitiesLoaded serverCapability: !capabilitiesLoaded
? PushCheck.unknown ? PushCheck.unknown
: (capabilityPush ? PushCheck.ok : PushCheck.fail), : (capabilityPush ? PushCheck.ok : PushCheck.fail),
appPasswordPresent: AccountData().hasAppPassword(), appPasswordPresent: nextcloud?.hasAppPassword ?? false,
talkAppPasswordPresent: AccountData().hasAppPasswordTalk(), talkAppPasswordPresent: nextcloud?.hasAppPasswordTalk ?? false,
keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false, keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false,
general: await typeStatus(PushRegistrationType.general), general: await typeStatus(PushRegistrationType.general),
talk: await typeStatus(PushRegistrationType.talk), talk: await typeStatus(PushRegistrationType.talk),
+8 -4
View File
@@ -9,8 +9,8 @@ import '../api/marianumcloud/talk/room/get_room_response.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../main.dart'; import '../main.dart';
import '../model/account_data.dart';
import '../notification/notification_tasks.dart'; import '../notification/notification_tasks.dart';
import '../session/session_manager.dart';
import '../share_intent/pending_share.dart'; import '../share_intent/pending_share.dart';
import '../share_intent/remote_file_ref.dart'; import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/app_modules.dart'; import '../state/app/modules/app_modules.dart';
@@ -374,7 +374,10 @@ class AppRoutes {
// ChatBloc._loadChat with the freshly-fetched maxId — sending one // ChatBloc._loadChat with the freshly-fetched maxId — sending one
// here too with the chat list's possibly-stale room.lastMessage.id // here too with the chat list's possibly-stale room.lastMessage.id
// would race the fresh one and could regress the server cursor. // would race the fresh one and could regress the server cursor.
context.read<ChatListBloc>().markRoomAsRead(room.token, room.lastMessage.id); context.read<ChatListBloc>().markRoomAsRead(
room.token,
room.lastMessage.id,
);
NotificationTasks.clearNotificationsForChat(room.token); NotificationTasks.clearNotificationsForChat(room.token);
TalkNavigator.pushSplitView( TalkNavigator.pushSplitView(
context, context,
@@ -404,7 +407,8 @@ class AppRoutes {
static ResolvedPendingChat? resolvePendingChat(BuildContext context) { static ResolvedPendingChat? resolvePendingChat(BuildContext context) {
final token = pendingChatToken.value; final token = pendingChatToken.value;
if (token == null) return null; if (token == null) return null;
if (!AccountData().isPopulated()) return null; final nextcloud = SessionManager().current?.nextcloud;
if (nextcloud == null) return null;
final rooms = context.read<ChatListBloc>().state.data?.rooms; final rooms = context.read<ChatListBloc>().state.data?.rooms;
final room = _findRoomByToken(rooms, token); final room = _findRoomByToken(rooms, token);
@@ -417,7 +421,7 @@ class AppRoutes {
); );
return ResolvedPendingChat( return ResolvedPendingChat(
room: room, room: room,
selfId: AccountData().getUsername(), selfId: nextcloud.username,
avatar: avatar, avatar: avatar,
); );
} }
+77
View File
@@ -0,0 +1,77 @@
import 'dart:convert';
/// Nextcloud identity of a session. Immutable; the session manager swaps in a
/// new instance whenever an app password is minted or revoked.
class NextcloudCredentials {
final String username;
/// The real account password. Invalid against Nextcloud when
/// [usesLoginFlow] is set (2FA accounts), where only [appPassword] works.
final String password;
final String? appPassword;
/// Backs the second (apptype=talk) push registration — Nextcloud binds each
/// push subscription to its session token, so two registrations need two
/// app passwords.
final String? appPasswordTalk;
/// True when the credentials came from Login Flow v2 (browser login, e.g.
/// because the account has two-factor authentication).
final bool usesLoginFlow;
const NextcloudCredentials({
required this.username,
required this.password,
this.appPassword,
this.appPasswordTalk,
this.usesLoginFlow = false,
});
bool get hasAppPassword => appPassword != null && appPassword!.isNotEmpty;
bool get hasAppPasswordTalk =>
appPasswordTalk != null && appPasswordTalk!.isNotEmpty;
/// The app password once available (minted or flow-issued), otherwise the
/// real password. It survives real-password rotation and is what the push
/// registration is bound to.
String get secret => hasAppPassword ? appPassword! : password;
/// HTTP Basic header value. Prefer headers over credentials in URLs — error
/// logs and crash reports often capture the URL but not headers.
String get basicAuthHeader => _basicAuth(secret);
Map<String, String> get authHeaders => {'Authorization': basicAuthHeader};
/// Authenticates the apptype=talk push registration (and its unregister).
/// Throws when the talk password has not been minted yet; callers treat that
/// as a failed talk registration and retry on the next start.
String get talkBasicAuthHeader {
if (hasAppPasswordTalk) return _basicAuth(appPasswordTalk!);
// Login-flow account whose second (talk) flow pass was skipped: no silent
// minting possible, the talk registration shares the flow credential.
if (usesLoginFlow && hasAppPassword) return _basicAuth(appPassword!);
throw StateError('Talk app password not available yet');
}
/// Always the real password. Needed to mint the app password via
/// `core/getapppassword` — an app password cannot mint another.
String get realPasswordBasicAuthHeader => _basicAuth(password);
NextcloudCredentials copyWith({
String? Function()? appPassword,
String? Function()? appPasswordTalk,
bool? usesLoginFlow,
}) => NextcloudCredentials(
username: username,
password: password,
appPassword: appPassword != null ? appPassword() : this.appPassword,
appPasswordTalk: appPasswordTalk != null
? appPasswordTalk()
: this.appPasswordTalk,
usesLoginFlow: usesLoginFlow ?? this.usesLoginFlow,
);
String _basicAuth(String secret) =>
'Basic ${base64Encode(utf8.encode('$username:$secret'))}';
}
+74
View File
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'nextcloud_credentials.dart';
/// The signed-in account. Exactly one session is active at a time; features
/// check for the backend identities they need ([nextcloud]) instead of
/// assuming every account has all of them.
sealed class Session {
/// Local demo session: every backend is served from fixtures (see DemoMode).
final bool isDemo;
const Session({this.isDemo = false});
/// Nextcloud identity, or null for accounts without one (guardians).
NextcloudCredentials? get nextcloud;
}
/// Student, teacher or staff account: username + password, backed by
/// MarianumConnect and Nextcloud (with the same username and password).
final class CredentialSession extends Session {
@override
final NextcloudCredentials nextcloud;
CredentialSession({
required String username,
required String password,
String? appPassword,
String? appPasswordTalk,
bool usesLoginFlow = false,
super.isDemo,
}) : nextcloud = NextcloudCredentials(
username: username,
password: password,
appPassword: appPassword,
appPasswordTalk: appPasswordTalk,
usesLoginFlow: usesLoginFlow,
);
const CredentialSession._(this.nextcloud, {super.isDemo});
String get username => nextcloud.username;
String get password => nextcloud.password;
CredentialSession withNextcloud(NextcloudCredentials nextcloud) =>
CredentialSession._(nextcloud, isDemo: isDemo);
/// Legacy MHSL identity (`sha512(user:pass)`), only for the one-off custom
/// events migration.
String get legacyUserSecret =>
sha512.convert(utf8.encode('$username:$password')).toString();
}
/// Parent/guardian account: passwordless e-mail login, MarianumConnect only.
final class GuardianSession extends Session {
final String email;
const GuardianSession({required this.email, super.isDemo});
@override
NextcloudCredentials? get nextcloud => null;
}
/// Thrown when a Nextcloud-only feature is reached with a session that has no
/// Nextcloud identity. Indicates a missing gate, not a user error.
class NextcloudUnavailableException implements Exception {
const NextcloudUnavailableException();
@override
String toString() =>
'NextcloudUnavailableException: session has no Nextcloud account';
}
+70
View File
@@ -0,0 +1,70 @@
import 'session.dart';
/// Keychain keys of the session. Names are frozen: installed versions and the
/// iOS AppDelegate/NSE read them, so renaming would log every user out.
abstract final class SessionKeys {
static const username = 'username';
static const password = 'password';
static const appPassword = 'nextcloud_app_password';
static const appPasswordTalk = 'nextcloud_app_password_talk';
static const loginFlow = 'nextcloud_login_flow';
static const demo = 'is_demo';
// Added with guardian accounts. Absent on installs from before — see
// [decodeSession].
static const kind = 'session_kind';
static const guardianEmail = 'guardian_email';
static const kindCredential = 'credential';
static const kindGuardian = 'guardian';
}
/// Rebuilds the session from raw keychain values. Installs from before
/// guardian accounts carry no [SessionKeys.kind]; a stored username and
/// password then mean a credential session, so existing users stay signed in.
Session? decodeSession(Map<String, String?> raw) {
final isDemo = raw[SessionKeys.demo] == 'true';
switch (raw[SessionKeys.kind]) {
case SessionKeys.kindGuardian:
final email = raw[SessionKeys.guardianEmail];
if (email == null || email.isEmpty) return null;
return GuardianSession(email: email, isDemo: isDemo);
case null:
case SessionKeys.kindCredential:
final username = raw[SessionKeys.username];
final password = raw[SessionKeys.password];
if (username == null || password == null) return null;
return CredentialSession(
username: username,
password: password,
appPassword: raw[SessionKeys.appPassword],
appPasswordTalk: raw[SessionKeys.appPasswordTalk],
usesLoginFlow: raw[SessionKeys.loginFlow] == 'true',
isDemo: isDemo,
);
default:
// Written by a newer app version; unknown here, treat as signed out.
return null;
}
}
/// Keychain values for [session], excluding the group-scoped app passwords
/// (written separately so the iOS NSE can read them). `null` = delete.
Map<String, String?> encodeSessionFields(Session session) => switch (session) {
CredentialSession() => {
SessionKeys.kind: SessionKeys.kindCredential,
SessionKeys.username: session.username,
SessionKeys.password: session.password,
SessionKeys.demo: session.isDemo ? 'true' : null,
SessionKeys.loginFlow: session.nextcloud.usesLoginFlow ? 'true' : null,
SessionKeys.guardianEmail: null,
},
GuardianSession() => {
SessionKeys.kind: SessionKeys.kindGuardian,
SessionKeys.guardianEmail: session.email,
SessionKeys.demo: session.isDemo ? 'true' : null,
SessionKeys.username: null,
SessionKeys.password: null,
SessionKeys.loginFlow: null,
},
};
+25
View File
@@ -0,0 +1,25 @@
import 'dart:developer';
import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
import '../auth_link/guardian_link_listener.dart';
import '../push/push_registration.dart';
import 'session_manager.dart';
abstract final class SessionLifecycle {
/// Ordered teardown: unregister push and revoke the Nextcloud app passwords
/// (while those credentials still exist), then revoke the MC bearer token,
/// finally wipe the local session. Each step is best-effort so an offline
/// sign-out still reaches a clean local state.
static Future<void> signOut() async {
try {
await PushRegistration().logoutCleanup();
} on Object catch (e) {
log('Sign-out: push cleanup failed: $e');
}
await AuthLogout().run();
await SessionManager().signOut();
// A login link that arrived while signed in must not be replayed on the
// login screen that follows.
GuardianLinkListener.clear();
}
}
+248
View File
@@ -0,0 +1,248 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../push/push_secure_storage.dart';
import '../utils/exponential_backoff.dart';
import 'nextcloud_credentials.dart';
import 'session.dart';
import 'session_codec.dart';
/// Owns the active [Session] and its persistence. One instance per isolate;
/// the widget background isolate reads the same keychain.
class SessionManager {
// `first_unlock` so a background launch on a locked device (silent push,
// BGAppRefresh) can still read the session. Items written by older versions
// carry the plugin default `unlocked` and are invisible to this instance
// until _migrateKeychainAccessibility moved them over.
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked),
);
static const List<String> _sessionFields = [
SessionKeys.kind,
SessionKeys.username,
SessionKeys.password,
SessionKeys.guardianEmail,
SessionKeys.demo,
SessionKeys.loginFlow,
];
static final SessionManager _instance = SessionManager._();
factory SessionManager() => _instance;
SessionManager._() {
unawaited(_loadWithRetry());
}
Completer<void> _loaded = Completer();
Session? _current;
Session? get current => _current;
bool get isSignedIn => _current != null;
bool get isDemo => _current?.isDemo ?? false;
/// Whether the session has a Nextcloud identity (Talk, Files, NC push).
bool get hasNextcloud => _current?.nextcloud != null;
/// True once the stored session has been read (or given up on).
bool get isLoaded => _loaded.isCompleted;
/// Resolves once the stored session is known. After [signOut] it stays
/// pending until the next sign-in.
Future<Session?> waitForLoad() async {
await _loaded.future;
return _current;
}
/// Stops waiting for the stored session; the app then behaves as signed
/// out. The keychain entries stay untouched so a later start can still
/// restore the session.
void abandonLoad() {
if (!_loaded.isCompleted) _loaded.complete();
}
/// Bumped when the server rejects a token that cannot be renewed silently
/// (passwordless accounts). The app confirms via [SessionValidator] before
/// signing out, so a transient 401 does not cost the session.
final ValueNotifier<int> unauthorizedSignal = ValueNotifier(0);
void reportUnauthorized() => unauthorizedSignal.value++;
NextcloudCredentials requireNextcloud() =>
_current?.nextcloud ?? (throw const NextcloudUnavailableException());
/// Replaces any stored session completely; no prior [signOut] needed.
Future<void> signIn(Session session) async {
await Future.wait([
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
_writeSecret(key, value),
_writeGroupSecret(
SessionKeys.appPassword,
session.nextcloud?.appPassword,
),
_writeGroupSecret(
SessionKeys.appPasswordTalk,
session.nextcloud?.appPasswordTalk,
),
]);
_current = session;
if (!_loaded.isCompleted) _loaded.complete();
}
Future<void> signOut() async {
_loaded = Completer();
_current = null;
await Future.wait([
for (final field in _sessionFields) _secureStorage.delete(key: field),
_writeGroupSecret(SessionKeys.appPassword, null),
_writeGroupSecret(SessionKeys.appPasswordTalk, null),
]);
}
/// Persists a freshly minted Nextcloud app password; from then on every
/// Nextcloud call authenticates with it instead of the real password.
Future<void> setAppPassword(String appPassword) async {
_updateNextcloud((nc) => nc.copyWith(appPassword: () => appPassword));
await _writeGroupSecret(SessionKeys.appPassword, appPassword);
}
Future<void> clearAppPassword() async {
_updateNextcloud((nc) => nc.copyWith(appPassword: () => null));
await _writeGroupSecret(SessionKeys.appPassword, null);
}
/// Adopts an app password obtained via Login Flow v2 and switches the
/// account into flow mode. A 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();
_updateNextcloud((nc) => nc.copyWith(usesLoginFlow: true));
await _secureStorage.write(key: SessionKeys.loginFlow, value: 'true');
}
Future<void> setAppPasswordTalk(String appPassword) async {
_updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => appPassword));
await _writeGroupSecret(SessionKeys.appPasswordTalk, appPassword);
}
Future<void> clearAppPasswordTalk() async {
_updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => null));
await _writeGroupSecret(SessionKeys.appPasswordTalk, null);
}
void _updateNextcloud(
NextcloudCredentials Function(NextcloudCredentials) update,
) {
final session = _current;
if (session is CredentialSession) {
_current = session.withNextcloud(update(session.nextcloud));
}
}
Future<void> _writeSecret(String key, String? value) => value == null
? _secureStorage.delete(key: key)
: _secureStorage.write(key: key, value: value);
// App passwords live in the push-shared (group-scoped) keystore so the iOS
// Notification Service Extension can authenticate Nextcloud calls too. That
// keystore may be unavailable (entitlement not provisioned); the in-memory
// copy still serves this session.
Future<void> _writeGroupSecret(String key, String? value) async {
try {
if (value == null) {
await pushSecureStorage.delete(key: key);
} else {
await pushSecureStorage.write(key: key, value: value);
}
} on Object {
// ignore — see above
}
}
/// iOS keychain reads fail while protected data is unavailable (app launch
/// racing the unlock, background wake on a locked device). Without a retry
/// the completer never resolved and the app stayed on the launch screen.
Future<void> _loadWithRetry() async {
for (var attempt = 1; !_loaded.isCompleted; attempt++) {
try {
await _migrateAndLoad();
return;
} catch (e, s) {
log('Session load failed (attempt $attempt): $e', stackTrace: s);
await Future<void>.delayed(exponentialBackoff(attempt));
}
}
}
Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage();
await _migrateKeychainAccessibility();
// On the startup critical path (and every background wake): read in
// parallel instead of one keychain round-trip after the other.
final values = await Future.wait(
_sessionFields.map((field) => _secureStorage.read(key: field)),
);
final raw = Map<String, String?>.fromIterables(_sessionFields, values);
try {
final (appPassword, appPasswordTalk) = await (
pushSecureStorage.read(key: SessionKeys.appPassword),
pushSecureStorage.read(key: SessionKeys.appPasswordTalk),
).wait;
raw[SessionKeys.appPassword] = appPassword;
raw[SessionKeys.appPasswordTalk] = appPasswordTalk;
} on Object {
// Group keystore unavailable: fall back to the real password.
}
_current = decodeSession(raw);
if (!_loaded.isCompleted) _loaded.complete();
}
// Move credentials from the old SharedPreferences plain-text storage into the
// platform's secure keystore. Run once per install and clear the legacy keys.
Future<void> _migrateFromLegacyStorage() async {
final prefs = await SharedPreferences.getInstance();
final legacyUsername = prefs.getString(SessionKeys.username);
final legacyPassword = prefs.getString(SessionKeys.password);
if (legacyUsername == null || legacyPassword == null) return;
final hasSecure =
(await _secureStorage.read(key: SessionKeys.username)) != null;
if (!hasSecure) {
await _secureStorage.write(
key: SessionKeys.username,
value: legacyUsername,
);
await _secureStorage.write(
key: SessionKeys.password,
value: legacyPassword,
);
}
await prefs.remove(SessionKeys.username);
await prefs.remove(SessionKeys.password);
}
Future<void> _migrateKeychainAccessibility() async {
if (!Platform.isIOS) return;
final legacyValues = await Future.wait(
_sessionFields.map((field) => _legacySecureStorage.read(key: field)),
);
for (final (i, field) in _sessionFields.indexed) {
final value = legacyValues[i];
if (value == null) continue;
// Same account+service: the legacy item has to go before the re-add.
await _legacySecureStorage.delete(key: field);
await _secureStorage.write(key: field, value: value);
}
}
}
@@ -2,7 +2,9 @@ import 'dart:developer';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/error_mapper.dart';
import '../../../../../session/session_manager.dart';
import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loadable_state.dart';
import '../../loadable_state/loading_error.dart'; import '../../loadable_state/loading_error.dart';
import '../../repository/repository.dart'; import '../../repository/repository.dart';
@@ -114,6 +116,13 @@ abstract class LoadableHydratedBloc<
add(Reset<TState>()); add(Reset<TState>());
} }
/// Backend identities the data needs. Without them loading is a no-op, so
/// reading the bloc in a session that lacks them (e.g. guardians and the
/// Nextcloud blocs) is harmless.
Set<AccessRequirement> get requirements => const {};
bool get requirementsMet => requirements.areMetBy(SessionManager().current);
TState? get innerState => state.data; TState? get innerState => state.data;
TRepository get repo => _repository; TRepository get repo => _repository;
@@ -137,6 +146,7 @@ abstract class LoadableHydratedBloc<
); );
void fetch() { void fetch() {
if (!requirementsMet) return;
log('Fetching data for ${TState.toString()}'); log('Fetching data for ${TState.toString()}');
gatherData() gatherData()
.catchError((Object e) { .catchError((Object e) {
+17 -3
View File
@@ -3,8 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../../../access/access_requirement.dart';
import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import '../../../routing/app_routes.dart'; import '../../../routing/app_routes.dart';
import '../../../session/session.dart';
import '../../../session/session_manager.dart';
import '../../../storage/modules_settings.dart'; import '../../../storage/modules_settings.dart';
import '../../../view/pages/absence_report/absence_report_view.dart'; import '../../../view/pages/absence_report/absence_report_view.dart';
import '../../../view/pages/files/files.dart'; import '../../../view/pages/files/files.dart';
@@ -38,6 +41,16 @@ class AppModule {
required this.create, required this.create,
}); });
/// Backend identities each module needs. Modules without an entry work for
/// every session.
static const Map<Modules, Set<AccessRequirement>> requirements = {
Modules.talk: {AccessRequirement.nextcloud},
Modules.files: {AccessRequirement.nextcloud},
};
static bool isAvailableFor(Modules module, Session? session) =>
(requirements[module] ?? const {}).areMetBy(session);
static Map<Modules, AppModule> modules( static Map<Modules, AppModule> modules(
BuildContext context, { BuildContext context, {
bool showFiltered = false, bool showFiltered = false,
@@ -146,6 +159,9 @@ class AppModule {
), ),
}; };
final session = SessionManager().current;
available.removeWhere((key, _) => !isAvailableFor(key, session));
if (!showFiltered) { if (!showFiltered) {
available.removeWhere( available.removeWhere(
(key, value) => (key, value) =>
@@ -177,9 +193,7 @@ class AppModule {
for (final missing in Modules.values) { for (final missing in Modules.values) {
if (!seen.add(missing)) continue; if (!seen.add(missing)) continue;
var insertAt = 0; var insertAt = 0;
for (final predecessor in Modules.values.takeWhile( for (final predecessor in Modules.values.takeWhile((m) => m != missing)) {
(m) => m != missing,
)) {
final pos = order.indexOf(predecessor); final pos = order.indexOf(predecessor);
if (pos >= insertAt) insertAt = pos + 1; if (pos >= insertAt) insertAt = pos + 1;
} }
@@ -5,6 +5,8 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/demo/data/demo_capabilities.dart'; import '../../../../../api/demo/data/demo_capabilities.dart';
import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart'; import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart';
import '../../../../../session/session.dart';
import '../../../../../session/session_manager.dart';
import 'capabilities_state.dart'; import 'capabilities_state.dart';
/// Holds the current user's mobile capability flags. Hydrated so the last /// Holds the current user's mobile capability flags. Hydrated so the last
@@ -21,17 +23,17 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
int? get timetableFutureDays => state.timetableFutureDays; 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 /// Refreshes capabilities from the server. On any failure (endpoint not yet
/// live, network error, 4xx) the previously hydrated flags are kept but the /// live, network error, 4xx) the previously hydrated flags are kept but the
/// state is marked `loaded` — a failed fetch never silently grants a /// state is marked `loaded` — a failed fetch never silently grants a
/// capability, and an offline launch keeps whatever was cached. /// capability, and an offline launch keeps whatever was cached.
Future<void> load() async { Future<void> load() async {
if (DemoMode.active) { if (DemoMode.active) {
emit(DemoCapabilities.state()); emit(
SessionManager().current is GuardianSession
? DemoCapabilities.guardianState()
: DemoCapabilities.state(),
);
return; return;
} }
try { try {
@@ -43,6 +45,7 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
timetablePastDays: response.timetablePastDays, timetablePastDays: response.timetablePastDays,
timetableFutureDays: response.timetableFutureDays, timetableFutureDays: response.timetableFutureDays,
userType: response.userType, userType: response.userType,
children: response.children,
loaded: true, loaded: true,
), ),
); );
@@ -1,10 +1,15 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../../access/user_role.dart';
import '../../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
part 'capabilities_state.freezed.dart'; part 'capabilities_state.freezed.dart';
part 'capabilities_state.g.dart'; part 'capabilities_state.g.dart';
@freezed @freezed
abstract class CapabilitiesState with _$CapabilitiesState { abstract class CapabilitiesState with _$CapabilitiesState {
const CapabilitiesState._();
const factory CapabilitiesState({ const factory CapabilitiesState({
@Default(false) bool viewForeignTimetables, @Default(false) bool viewForeignTimetables,
@Default(false) bool pushNotifications, @Default(false) bool pushNotifications,
@@ -12,8 +17,10 @@ abstract class CapabilitiesState with _$CapabilitiesState {
// client-side clamp; the (server-narrowed) school year alone governs. // client-side clamp; the (server-narrowed) school year alone governs.
int? timetablePastDays, int? timetablePastDays,
int? timetableFutureDays, int? timetableFutureDays,
// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown. // Wire value of the user type; read it through [role].
String? userType, String? userType,
// Students a guardian may see; empty for all other accounts.
@Default(<GuardianChild>[]) List<GuardianChild> children,
// Whether a capability response (or a definitive failure) has been // Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still // observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed". // unknown" from "confirmed not allowed".
@@ -22,4 +29,6 @@ abstract class CapabilitiesState with _$CapabilitiesState {
factory CapabilitiesState.fromJson(Map<String, Object?> json) => factory CapabilitiesState.fromJson(Map<String, Object?> json) =>
_$CapabilitiesStateFromJson(json); _$CapabilitiesStateFromJson(json);
UserRole get role => UserRole.parse(userType);
} }
@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint // ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'capabilities_state.dart'; part of 'capabilities_state.dart';
@@ -9,19 +9,14 @@ part of 'capabilities_state.dart';
// FreezedGenerator // FreezedGenerator
// ************************************************************************** // **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off // dart format off
T _$identity<T>(T value) => value; T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$CapabilitiesState { mixin _$CapabilitiesState {
bool get viewForeignTimetables; bool get pushNotifications;// Days into the past/future the timetable may be scrolled. Null = no bool get viewForeignTimetables; bool get pushNotifications; int? get timetablePastDays; int? get timetableFutureDays; String? get userType; List<GuardianChild> get children; bool get loaded;
// 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;
/// Create a copy of CapabilitiesState /// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -34,16 +29,21 @@ $CapabilitiesStateCopyWith<CapabilitiesState> get copyWith => _$CapabilitiesStat
@override @override
bool operator ==(Object other) { 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.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)); final _this = this as CapabilitiesState;
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, _this.viewForeignTimetables) || other.viewForeignTimetables == _this.viewForeignTimetables)&&(identical(other.pushNotifications, _this.pushNotifications) || other.pushNotifications == _this.pushNotifications)&&(identical(other.timetablePastDays, _this.timetablePastDays) || other.timetablePastDays == _this.timetablePastDays)&&(identical(other.timetableFutureDays, _this.timetableFutureDays) || other.timetableFutureDays == _this.timetableFutureDays)&&(identical(other.userType, _this.userType) || other.userType == _this.userType)&&const DeepCollectionEquality().equals(other.children, _this.children)&&(identical(other.loaded, _this.loaded) || other.loaded == _this.loaded));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded); int get hashCode {
final _this = this as CapabilitiesState;
return Object.hash(runtimeType,_this.viewForeignTimetables,_this.pushNotifications,_this.timetablePastDays,_this.timetableFutureDays,_this.userType,const DeepCollectionEquality().hash(_this.children),_this.loaded);
}
@override @override
String toString() { String toString() {
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)'; final _this = this as CapabilitiesState;
return 'CapabilitiesState(viewForeignTimetables: ${_this.viewForeignTimetables}, pushNotifications: ${_this.pushNotifications}, timetablePastDays: ${_this.timetablePastDays}, timetableFutureDays: ${_this.timetableFutureDays}, userType: ${_this.userType}, children: ${_this.children}, loaded: ${_this.loaded})';
} }
@@ -54,7 +54,7 @@ abstract mixin class $CapabilitiesStateCopyWith<$Res> {
factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl; factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded
}); });
@@ -71,14 +71,15 @@ class _$CapabilitiesStateCopyWithImpl<$Res>
/// Create a copy of CapabilitiesState /// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,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? children = null,Object? loaded = null,}) {
return _then(_self.copyWith( return _then(CapabilitiesState(
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable 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,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // 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?,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 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 String?,children: null == children ? _self.children : children // ignore: cast_nullable_to_non_nullable
as List<GuardianChild>,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
as bool, as bool,
)); ));
} }
@@ -164,10 +165,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@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; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _CapabilitiesState() when $default != null: case _CapabilitiesState() when $default != null:
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _: return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded);case _:
return orElse(); return orElse();
} }
@@ -185,10 +186,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _CapabilitiesState(): case _CapabilitiesState():
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _: return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded);case _:
throw StateError('Unexpected subclass'); throw StateError('Unexpected subclass');
} }
@@ -205,10 +206,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _CapabilitiesState() when $default != null: case _CapabilitiesState() when $default != null:
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _: return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded);case _:
return null; return null;
} }
@@ -219,21 +220,22 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _CapabilitiesState implements CapabilitiesState { class _CapabilitiesState extends CapabilitiesState {
const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, this.loaded = false}); const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, List<GuardianChild> children = const <GuardianChild>[], this.loaded = false}): _children = children,super._();
factory _CapabilitiesState.fromJson(Map<String, dynamic> json) => _$CapabilitiesStateFromJson(json); factory _CapabilitiesState.fromJson(Map<String, dynamic> json) => _$CapabilitiesStateFromJson(json);
@override@JsonKey() final bool viewForeignTimetables; @override@JsonKey() final bool viewForeignTimetables;
@override@JsonKey() final bool pushNotifications; @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? timetablePastDays;
@override final int? timetableFutureDays; @override final int? timetableFutureDays;
// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
@override final String? userType; @override final String? userType;
// Whether a capability response (or a definitive failure) has been final List<GuardianChild> _children;
// observed at least once this session. Lets the UI distinguish "still @override@JsonKey() List<GuardianChild> get children {
// unknown" from "confirmed not allowed". if (_children is EqualUnmodifiableListView) return _children;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_children);
}
@override@JsonKey() final bool loaded; @override@JsonKey() final bool loaded;
/// Create a copy of CapabilitiesState /// Create a copy of CapabilitiesState
@@ -249,16 +251,18 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { 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.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)); 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)&&const DeepCollectionEquality().equals(other.children, _children)&&(identical(other.loaded, loaded) || other.loaded == loaded));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded); int get hashCode {
return Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,const DeepCollectionEquality().hash(_children),loaded);
}
@override @override
String toString() { String toString() {
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)'; return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, children: $children, loaded: $loaded)';
} }
@@ -269,7 +273,7 @@ abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesS
factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl; factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded
}); });
@@ -286,14 +290,15 @@ class __$CapabilitiesStateCopyWithImpl<$Res>
/// Create a copy of CapabilitiesState /// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,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? children = null,Object? loaded = null,}) {
return _then(_CapabilitiesState( return _then(_CapabilitiesState(
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable 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,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // 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?,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 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 String?,children: null == children ? _self._children : children // ignore: cast_nullable_to_non_nullable
as List<GuardianChild>,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
as bool, as bool,
)); ));
} }
@@ -13,6 +13,11 @@ _CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) =>
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
userType: json['userType'] as String?, userType: json['userType'] as String?,
children:
(json['children'] as List<dynamic>?)
?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
.toList() ??
const <GuardianChild>[],
loaded: json['loaded'] as bool? ?? false, loaded: json['loaded'] as bool? ?? false,
); );
@@ -23,5 +28,6 @@ Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) =>
'timetablePastDays': instance.timetablePastDays, 'timetablePastDays': instance.timetablePastDays,
'timetableFutureDays': instance.timetableFutureDays, 'timetableFutureDays': instance.timetableFutureDays,
'userType': instance.userType, 'userType': instance.userType,
'children': instance.children,
'loaded': instance.loaded, 'loaded': instance.loaded,
}; };
@@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -43,6 +44,11 @@ class ChatListBloc
}); });
} }
@override
Set<AccessRequirement> get requirements => const {
AccessRequirement.nextcloud,
};
@override @override
ChatListRepository repository() => ChatListRepository(); ChatListRepository repository() => ChatListRepository();
@@ -73,6 +79,7 @@ class ChatListBloc
} }
Future<void> refresh({bool renew = true, bool silent = false}) async { Future<void> refresh({bool renew = true, bool silent = false}) async {
if (!requirementsMet) return;
if (!silent) add(RefetchStarted<ChatListState>()); if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError; Object? capturedError;
try { try {
@@ -0,0 +1,33 @@
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
/// The child a guardian is currently looking at. Shared by every module that
/// shows per-child data (timetable, absence report, later messages), so
/// switching the child in one place switches it everywhere.
class ChildSelectionCubit extends HydratedCubit<String?> {
ChildSelectionCubit() : super(null);
void select(String childId) => emit(childId);
Future<void> reset() async => emit(null);
@override
String? fromJson(Map<String, dynamic> json) => json['childId'] as String?;
@override
Map<String, dynamic>? toJson(String? state) => {'childId': state};
}
/// The selected child if it is still linked, otherwise the first one. Null
/// when there are no children.
GuardianChild? effectiveChild(
List<GuardianChild> children,
String? selectedId,
) {
if (children.isEmpty) return null;
for (final child in children) {
if (child.id == selectedId) return child;
}
return children.first;
}
@@ -1,204 +0,0 @@
import 'dart:developer';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../extensions/date_time.dart';
import '../../../infrastructure/loadable_state/loadable_state.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../../timetable/bloc/timetable_event.dart';
import '../../timetable/bloc/timetable_state.dart';
import '../repository/foreign_timetable_repository.dart';
/// Drives a foreign element's timetable. Mirrors `TimetableBloc`'s week-loading
/// and navigation but loads weeks from the element endpoint, carries no custom
/// events, and does not persist (page-scoped, recreated per element). Reuses
/// [TimetableState] verbatim so the render pipeline is unchanged; `customEvents`
/// stays null (the foreign view's `isReady` predicate ignores it).
class ForeignTimetableBloc
extends
LoadableHydratedBloc<
TimetableEvent,
TimetableState,
ForeignTimetableRepository
> {
final TimetableElementType type;
// Named `elementId` rather than `id` to avoid shadowing HydratedMixin's
// `String get id` (the storage key), which a plain `int id` would illegally
// override.
final int elementId;
final String title;
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
ForeignTimetableBloc({
required this.type,
required this.elementId,
required this.title,
});
@override
ForeignTimetableRepository repository() => ForeignTimetableRepository();
@override
TimetableState fromNothing() {
final reference = DateTime.now().addDays(2);
return TimetableState(
startDate: _startOfWeek(reference),
endDate: _endOfWeek(reference),
);
}
// Persistence disabled: page-scoped and element-specific, nothing worth
// restoring. toJson returns null so nothing is written; fromJson starts fresh.
@override
Map<String, dynamic>? toJson(LoadableState<TimetableState> state) => null;
@override
LoadableState<TimetableState> fromJson(Map<String, dynamic> json) =>
const LoadableState(
isLoading: true,
data: null,
lastFetch: null,
reFetch: null,
error: null,
);
@override
TimetableState fromStorage(Map<String, dynamic> json) => fromNothing();
@override
Map<String, dynamic>? toStorage(TimetableState state) => null;
@override
Future<void> gatherData() async {
final initial = innerState ?? fromNothing();
Object? firstError;
void recordError(Object e) {
firstError ??= e;
}
await Future.wait([
_loadCurrentWeek(initial.startDate, initial.endDate, onError: recordError),
_loadStaticReferenceData(onError: recordError),
]);
if (firstError != null) throw firstError!;
add(DataGathered((s) => s));
_prefetchAdjacentWeeks(initial.startDate, initial.endDate);
}
void changeWeek(DateTime startDate, DateTime endDate) {
final current = innerState ?? fromNothing();
if (current.startDate == startDate && current.endDate == endDate) return;
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
_loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
}
void resetWeek() {
final reference = DateTime.now().addDays(2);
changeWeek(_startOfWeek(reference), _endOfWeek(reference));
}
void refresh() => fetch();
Future<void> _loadCurrentWeek(
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
}) async {
final requestStart = DateTime.now();
_lastWeekRequestStart = requestStart;
try {
final week = await repo.data.getElementWeek(
type,
elementId,
startDate,
endDate,
onError: onError,
);
if (_lastWeekRequestStart.isAfter(requestStart)) return;
_writeWeekToCache(startDate, week);
} catch (e) {
log('getElementWeek error for $startDate$endDate: $e');
onError?.call(e);
}
}
Future<void> _loadStaticReferenceData({
void Function(Object)? onError,
}) async {
try {
final (rooms, subjects, schoolHolidays, schoolyear) = await (
repo.data.getRooms(onError: onError),
repo.data.getSubjects(onError: onError),
repo.data.getSchoolHolidays(onError: onError),
repo.data.getCurrentSchoolyear(onError: onError),
).wait;
add(
Emit(
(s) => s.copyWith(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
dataVersion: s.dataVersion + 1,
),
),
);
} catch (e) {
onError?.call(e);
}
try {
final timegrid = await repo.data.getTimegrid();
add(
Emit(
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
),
);
} catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI.
}
}
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
_prefetchWeek(start.subtractDays(7), end.subtractDays(7));
_prefetchWeek(start.addDays(7), end.addDays(7));
}
void _prefetchWeek(DateTime start, DateTime end) {
repo.data
.getElementWeek(type, elementId, start, end)
.then((week) => _writeWeekToCache(start, week))
.catchError((_) {});
}
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
final key = weekStart.weekKey();
add(
Emit((s) {
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
updated[key] = week;
return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1);
}),
);
}
static DateTime _startOfWeek(DateTime reference) {
final monday = reference.subtractDays(reference.weekday - 1);
return DateTime(monday.year, monday.month, monday.day);
}
static DateTime _endOfWeek(DateTime reference) {
final friday = reference.addDays(
DateTime.daysPerWeek - reference.weekday - 2,
);
return DateTime(friday.year, friday.month, friday.day);
}
}
@@ -1,64 +0,0 @@
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../timetable/data_provider/timetable_data_provider.dart';
/// Data access for a foreign element's timetable. The week comes from the
/// element-specific endpoint; all reference data (rooms/subjects/holidays/
/// school year/timegrid) is school-wide, so it delegates to the existing
/// [TimetableDataProvider] (which caches it). Custom events are intentionally
/// absent — they are user-private.
class ForeignTimetableDataProvider {
final TimetableDataProvider _base;
ForeignTimetableDataProvider([TimetableDataProvider? base])
: _base = base ?? TimetableDataProvider();
Future<TimetableGetWeekResponse> getElementWeek(
TimetableElementType type,
int id,
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
}) async {
try {
return await TimetableGetElementWeek().run(
type: type,
id: id,
from: startDate,
until: endDate,
);
} catch (e) {
onError?.call(e);
rethrow;
}
}
Future<TimetableGetRoomsResponse> getRooms({
void Function(Object)? onError,
bool renew = false,
}) => _base.getRooms(onError: onError, renew: renew);
Future<TimetableGetSubjectsResponse> getSubjects({
void Function(Object)? onError,
bool renew = false,
}) => _base.getSubjects(onError: onError, renew: renew);
Future<TimetableGetHolidaysResponse> getSchoolHolidays({
void Function(Object)? onError,
bool renew = false,
}) => _base.getSchoolHolidays(onError: onError, renew: renew);
Future<TimetableGetSchoolyearResponse> getCurrentSchoolyear({
void Function(Object)? onError,
bool renew = false,
}) => _base.getCurrentSchoolyear(onError: onError, renew: renew);
Future<TimetableGetTimegridResponse> getTimegrid({bool renew = false}) =>
_base.getTimegrid(renew: renew);
}
@@ -1,12 +0,0 @@
import '../../../infrastructure/repository/repository.dart';
import '../../timetable/bloc/timetable_state.dart';
import '../data_provider/foreign_timetable_data_provider.dart';
class ForeignTimetableRepository extends Repository<TimetableState> {
final ForeignTimetableDataProvider _provider;
ForeignTimetableRepository([ForeignTimetableDataProvider? provider])
: _provider = provider ?? ForeignTimetableDataProvider();
ForeignTimetableDataProvider get data => _provider;
}
@@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/demo/data/demo_capabilities.dart'; import '../../../../../api/demo/data/demo_capabilities.dart';
import '../../../../../api/demo/demo_mode.dart'; import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart'; import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart';
import '../../../../../session/session_manager.dart';
import 'nextcloud_capabilities_state.dart'; import 'nextcloud_capabilities_state.dart';
/// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so /// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so
@@ -59,6 +60,7 @@ class NextcloudCapabilitiesCubit
/// Refreshes capabilities from the server. On any failure the previously /// Refreshes capabilities from the server. On any failure the previously
/// hydrated flags are kept but the state is marked `loaded`. /// hydrated flags are kept but the state is marked `loaded`.
Future<void> load() async { Future<void> load() async {
if (!SessionManager().hasNextcloud) return;
if (DemoMode.active) { if (DemoMode.active) {
emit(DemoNextcloudCapabilities.state()); emit(DemoNextcloudCapabilities.state());
return; return;
@@ -4,12 +4,17 @@ import '../../../../../api/marianumconnect/queries/timetable_custom_events/custo
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../../extensions/date_time.dart'; import '../../../../../extensions/date_time.dart';
import '../../../infrastructure/loadable_state/loadable_state.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/timetable_repository.dart'; import '../repository/timetable_repository.dart';
import '../subject/timetable_subject.dart';
import 'timetable_event.dart'; import 'timetable_event.dart';
import 'timetable_state.dart'; import 'timetable_state.dart';
/// Drives one [TimetableSubject]'s plan. The same class serves the own plan
/// and foreign element plans; everything subject-specific (endpoint,
/// persistence, custom events) is derived from [subject].
class TimetableBloc class TimetableBloc
extends extends
LoadableHydratedBloc< LoadableHydratedBloc<
@@ -17,6 +22,13 @@ class TimetableBloc
TimetableState, TimetableState,
TimetableRepository TimetableRepository
> { > {
final TimetableSubject subject;
TimetableBloc({required this.subject});
@override
String get id => subject.storageId;
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
/// Set by [retry] to force the next [gatherData] to bypass cache freshness /// Set by [retry] to force the next [gatherData] to bypass cache freshness
@@ -59,8 +71,25 @@ class TimetableBloc
@override @override
Map<String, dynamic>? toStorage(TimetableState state) => state.toJson(); Map<String, dynamic>? toStorage(TimetableState state) => state.toJson();
@override
Map<String, dynamic>? toJson(LoadableState<TimetableState> state) =>
subject.persistent ? super.toJson(state) : null;
@override
LoadableState<TimetableState> fromJson(Map<String, dynamic> json) =>
subject.persistent
? super.fromJson(json)
: const LoadableState(
isLoading: true,
data: null,
lastFetch: null,
reFetch: null,
error: null,
);
@override @override
Future<void> gatherData() async { Future<void> gatherData() async {
if (subject is NoTimetable) return;
final initial = innerState ?? fromNothing(); final initial = innerState ?? fromNothing();
final renew = _forceRenew; final renew = _forceRenew;
_forceRenew = false; _forceRenew = false;
@@ -75,9 +104,9 @@ class TimetableBloc
initial.startDate, initial.startDate,
initial.endDate, initial.endDate,
onError: recordError, onError: recordError,
renew: renew,
), ),
_loadStaticReferenceData(onError: recordError, renew: renew), _loadStaticReferenceData(onError: recordError, renew: renew),
if (subject.supportsCustomEvents)
_loadCustomEvents(onError: recordError, renew: renew), _loadCustomEvents(onError: recordError, renew: renew),
]); ]);
@@ -102,17 +131,28 @@ class TimetableBloc
void refresh() => fetch(); void refresh() => fetch();
/// Custom events belong to the signed-in user's own plan only — never to a
/// foreign plan or a guardian's view of a child.
void _requireCustomEvents() {
if (!subject.supportsCustomEvents) {
throw StateError('Custom events are not available for $subject');
}
}
Future<void> addCustomEvent(CustomTimetableEvent event) async { Future<void> addCustomEvent(CustomTimetableEvent event) async {
_requireCustomEvents();
await repo.data.addCustomEvent(event); await repo.data.addCustomEvent(event);
await _refreshCustomEvents(); await _refreshCustomEvents();
} }
Future<void> updateCustomEvent(String id, CustomTimetableEvent event) async { Future<void> updateCustomEvent(String id, CustomTimetableEvent event) async {
_requireCustomEvents();
await repo.data.updateCustomEvent(id, event); await repo.data.updateCustomEvent(id, event);
await _refreshCustomEvents(); await _refreshCustomEvents();
} }
Future<void> removeCustomEvent(String id) async { Future<void> removeCustomEvent(String id) async {
_requireCustomEvents();
await repo.data.removeCustomEvent(id); await repo.data.removeCustomEvent(id);
await _refreshCustomEvents(); await _refreshCustomEvents();
} }
@@ -142,16 +182,15 @@ class TimetableBloc
DateTime startDate, DateTime startDate,
DateTime endDate, { DateTime endDate, {
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false,
}) async { }) async {
final requestStart = DateTime.now(); final requestStart = DateTime.now();
_lastWeekRequestStart = requestStart; _lastWeekRequestStart = requestStart;
try { try {
final week = await repo.data.getWeek( final week = await repo.data.getWeek(
subject,
startDate, startDate,
endDate, endDate,
onError: onError, onError: onError,
renew: renew,
); );
if (_lastWeekRequestStart.isAfter(requestStart)) return; if (_lastWeekRequestStart.isAfter(requestStart)) return;
_writeWeekToCache(startDate, week); _writeWeekToCache(startDate, week);
@@ -237,7 +276,7 @@ class TimetableBloc
void _prefetchWeek(DateTime start, DateTime end) { void _prefetchWeek(DateTime start, DateTime end) {
repo.data repo.data
.getWeek(start, end) .getWeek(subject, start, end)
.then((week) => _writeWeekToCache(start, week)) .then((week) => _writeWeekToCache(start, week))
.catchError((_) {}); .catchError((_) {});
} }
@@ -265,3 +304,11 @@ class TimetableBloc
return DateTime(friday.year, friday.month, friday.day); return DateTime(friday.year, friday.month, friday.day);
} }
} }
/// Type token for a page-scoped plan (e.g. a foreign element). Carries no
/// logic of its own; the distinct type keeps a page-local provider from
/// shadowing the app-wide [TimetableBloc] that sheets and root-navigator pages
/// (subject colours, custom events) read.
final class ScopedTimetableBloc extends TimetableBloc {
ScopedTimetableBloc({required super.subject});
}
@@ -40,9 +40,12 @@ abstract class TimetableState with _$TimetableState {
Iterable<McTimetableEntry> getAllKnownLessons() => Iterable<McTimetableEntry> getAllKnownLessons() =>
weekCache.values.expand((response) => response.entries); weekCache.values.expand((response) => response.entries);
bool get hasReferenceData => /// Whether the calendar has everything it needs to render. Custom events
/// only exist for subjects that support them; requiring them elsewhere would
/// keep foreign plans loading forever.
bool isReady({required bool needsCustomEvents}) =>
rooms != null && rooms != null &&
subjects != null && subjects != null &&
schoolHolidays != null && schoolHolidays != null &&
customEvents != null; (!needsCustomEvents || customEvents != null);
} }
@@ -4,6 +4,8 @@ import '../../../../../api/marianumconnect/queries/timetable_custom_events/timet
import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_cache.dart'; import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_cache.dart';
import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_remove.dart'; import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_remove.dart';
import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_update.dart'; import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_update.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_child_week/timetable_get_child_week.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
@@ -21,19 +23,43 @@ import '../../../../../api/marianumconnect/queries/timetable_subject_colors/time
import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../../../../../api/request_cache.dart'; import '../../../../../api/request_cache.dart';
import '../subject/timetable_subject.dart';
/// Pulls the timetable from the Marianum-Connect mobile API. Each endpoint is /// Pulls the timetable from the Marianum-Connect mobile API. Each endpoint is
/// its own HTTP call; this provider exposes the lazy futures so the bloc can /// its own HTTP call; this provider exposes the lazy futures so the bloc can
/// chain them without seeing the dio layer. /// chain them without seeing the dio layer. Only the week depends on the
/// [TimetableSubject]; the reference data is school-wide.
class TimetableDataProvider { class TimetableDataProvider {
/// The endpoint serving [subject]'s week. Shared with the widget background
/// isolate, which has no bloc.
static Future<TimetableGetWeekResponse> fetchWeek(
TimetableSubject subject, {
required DateTime from,
required DateTime until,
}) => switch (subject) {
OwnTimetable() => TimetableGetWeek().run(from: from, until: until),
ElementTimetable(:final element) => TimetableGetElementWeek().run(
type: element.type,
id: element.id,
from: from,
until: until,
),
ChildTimetable(:final childId) => TimetableGetChildWeek().run(
childId: childId,
from: from,
until: until,
),
NoTimetable() => throw StateError('No timetable subject'),
};
Future<TimetableGetWeekResponse> getWeek( Future<TimetableGetWeekResponse> getWeek(
TimetableSubject subject,
DateTime startDate, DateTime startDate,
DateTime endDate, { DateTime endDate, {
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false,
}) async { }) async {
try { try {
return await TimetableGetWeek().run(from: startDate, until: endDate); return await fetchWeek(subject, from: startDate, until: endDate);
} catch (e) { } catch (e) {
onError?.call(e); onError?.call(e);
rethrow; rethrow;
@@ -0,0 +1,54 @@
import '../../../../../access/user_role.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../capabilities/bloc/capabilities_state.dart';
import '../subject/timetable_subject.dart';
/// What the timetable view offers for a given subject. Resolved in one place
/// so the view never branches on roles or subject types itself.
class TimetablePolicy {
final bool canManageCustomEvents;
final bool canEditSubjectColors;
final bool showClassInsteadOfTeacher;
final bool canOpenForeign;
const TimetablePolicy({
required this.canManageCustomEvents,
required this.canEditSubjectColors,
required this.showClassInsteadOfTeacher,
required this.canOpenForeign,
});
static TimetablePolicy resolve({
required TimetableSubject subject,
required CapabilitiesState capabilities,
}) => switch (subject) {
OwnTimetable() => TimetablePolicy(
canManageCustomEvents: true,
canEditSubjectColors: true,
showClassInsteadOfTeacher: capabilities.role == UserRole.teacher,
canOpenForeign: capabilities.viewForeignTimetables,
),
// Subject colours are the viewer's own, global setting; editing them from
// a foreign plan would not refresh that plan, so it is not offered there.
ElementTimetable(:final element) => TimetablePolicy(
canManageCustomEvents: false,
canEditSubjectColors: false,
showClassInsteadOfTeacher: element.type == TimetableElementType.teacher,
canOpenForeign: capabilities.viewForeignTimetables,
),
// Custom events are the child's private data; subject colours are the
// guardian's own and apply to this (primary) plan directly.
ChildTimetable() => TimetablePolicy(
canManageCustomEvents: false,
canEditSubjectColors: true,
showClassInsteadOfTeacher: false,
canOpenForeign: capabilities.viewForeignTimetables,
),
NoTimetable() => const TimetablePolicy(
canManageCustomEvents: false,
canEditSubjectColors: false,
showClassInsteadOfTeacher: false,
canOpenForeign: false,
),
};
}
@@ -0,0 +1,18 @@
import '../../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../../../../../session/session.dart';
import '../../children/child_selection_cubit.dart';
import '../subject/timetable_subject.dart';
/// Whose plan the timetable tab shows for the active session.
TimetableSubject resolvePrimarySubject({
required Session? session,
required List<GuardianChild> children,
required String? selectedChildId,
}) => switch (session) {
null => const NoTimetable(),
CredentialSession() => const OwnTimetable(),
GuardianSession() => switch (effectiveChild(children, selectedChildId)) {
null => const NoTimetable(),
final child => ChildTimetable(child.id),
},
};
@@ -0,0 +1,81 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../session/session_manager.dart';
import '../../account/bloc/account_bloc.dart';
import '../../account/bloc/account_state.dart';
import '../../capabilities/bloc/capabilities_cubit.dart';
import '../../capabilities/bloc/capabilities_state.dart';
import '../../children/child_selection_cubit.dart';
import '../bloc/timetable_bloc.dart';
import '../subject/timetable_subject.dart';
import 'primary_subject_resolver.dart';
/// Provides the app-wide [TimetableBloc] for the session's primary subject
/// (own plan, or the selected child for guardians) and swaps it for a fresh
/// instance when that subject changes.
///
/// Sits above MaterialApp so root-navigator pages (subject colours, custom
/// events) reach it. The widget subtree is kept on a swap — only the provided
/// instance changes, which BlocBuilder/BlocListener pick up — so switching
/// the child does not reset the navigation. A new instance per subject (rather
/// than retargeting one bloc) keeps late responses for the previous child out
/// of the new child's week cache.
class PrimaryTimetableScope extends StatefulWidget {
final Widget child;
const PrimaryTimetableScope({required this.child, super.key});
@override
State<PrimaryTimetableScope> createState() => _PrimaryTimetableScopeState();
}
class _PrimaryTimetableScopeState extends State<PrimaryTimetableScope> {
late TimetableBloc _bloc;
@override
void initState() {
super.initState();
_bloc = TimetableBloc(subject: _resolve());
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
TimetableSubject _resolve() => resolvePrimarySubject(
session: SessionManager().current,
children: context.read<CapabilitiesCubit>().state.children,
selectedChildId: context.read<ChildSelectionCubit>().state,
);
void _sync() {
final subject = _resolve();
if (subject == _bloc.subject) return;
final previous = _bloc;
setState(() => _bloc = TimetableBloc(subject: subject));
// Dependents re-subscribe during the next build; close afterwards.
WidgetsBinding.instance.addPostFrameCallback((_) => previous.close());
}
@override
Widget build(BuildContext context) => MultiBlocListener(
listeners: [
BlocListener<AccountBloc, AccountState>(
listenWhen: (a, b) => a.status != b.status,
listener: (_, _) => _sync(),
),
BlocListener<CapabilitiesCubit, CapabilitiesState>(
listenWhen: (a, b) => a.children != b.children,
listener: (_, _) => _sync(),
),
BlocListener<ChildSelectionCubit, String?>(listener: (_, _) => _sync()),
],
child: BlocProvider<TimetableBloc>.value(
value: _bloc,
child: widget.child,
),
);
}
@@ -0,0 +1,110 @@
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
/// Whose timetable a [TimetableBloc] shows. The render pipeline is identical
/// for every subject; only the week endpoint, persistence and the
/// user-private extras (custom events) differ.
sealed class TimetableSubject {
const TimetableSubject();
/// Suffix of the hydrated storage slot. Must be unique per subject so
/// subjects never overwrite each other's cached weeks.
String get storageId;
/// Whether the bloc keeps its state across app restarts.
bool get persistent;
/// Custom events are user-private and only exist for the own plan.
bool get supportsCustomEvents;
}
/// The signed-in user's own plan (`timetable/me`).
final class OwnTimetable extends TimetableSubject {
const OwnTimetable();
// Empty on purpose: keeps the pre-existing storage slot "TimetableBloc", so
// updating the app does not drop the cached weeks.
@override
String get storageId => '';
@override
bool get persistent => true;
@override
bool get supportsCustomEvents => true;
@override
bool operator ==(Object other) => other is OwnTimetable;
@override
int get hashCode => (OwnTimetable).hashCode;
}
/// A foreign element picked by the user (teacher, room, class, student).
final class ElementTimetable extends TimetableSubject {
final TimetableElementRef element;
const ElementTimetable(this.element);
@override
String get storageId => 'element-${element.type.name}-${element.id}';
@override
bool get persistent => false;
@override
bool get supportsCustomEvents => false;
@override
bool operator ==(Object other) =>
other is ElementTimetable &&
other.element.type == element.type &&
other.element.id == element.id;
@override
int get hashCode => Object.hash(element.type, element.id);
}
/// A guardian's child (`timetable/child/{id}`). Kept across restarts per
/// child, so switching between siblings shows the cached plan immediately.
final class ChildTimetable extends TimetableSubject {
final String childId;
const ChildTimetable(this.childId);
@override
String get storageId => 'child-$childId';
@override
bool get persistent => true;
@override
bool get supportsCustomEvents => false;
@override
bool operator ==(Object other) =>
other is ChildTimetable && other.childId == childId;
@override
int get hashCode => childId.hashCode;
}
/// No plan to show: signed out, or a guardian without (known) children. The
/// bloc loads nothing; the view explains why.
final class NoTimetable extends TimetableSubject {
const NoTimetable();
@override
String get storageId => 'none';
@override
bool get persistent => false;
@override
bool get supportsCustomEvents => false;
@override
bool operator ==(Object other) => other is NoTimetable;
@override
int get hashCode => (NoTimetable).hashCode;
}
+2 -2
View File
@@ -6,8 +6,8 @@ import 'package:background_downloader/background_downloader.dart' as bd;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../../api/marianumcloud/webdav/webdav_api.dart'; import '../../api/marianumcloud/webdav/webdav_api.dart';
import '../../model/account_data.dart';
import '../../notification/notification_service.dart'; import '../../notification/notification_service.dart';
import '../../session/session_manager.dart';
import '../../share_intent/remote_file_ref.dart'; import '../../share_intent/remote_file_ref.dart';
import 'download_job.dart'; import 'download_job.dart';
@@ -116,7 +116,7 @@ class DownloadManager {
final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/'); final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/');
final task = bd.DownloadTask( final task = bd.DownloadTask(
url: '${WebdavApi.buildWebdavUrl()}$encodedPath', url: '${WebdavApi.buildWebdavUrl()}$encodedPath',
headers: AccountData().authHeaders(), headers: SessionManager().requireNextcloud().authHeaders,
filename: name, filename: name,
baseDirectory: bd.BaseDirectory.temporary, baseDirectory: bd.BaseDirectory.temporary,
directory: _directory, directory: _directory,
+10
View File
@@ -0,0 +1,10 @@
import 'dart:math';
/// Random hex id from a cryptographic RNG, e.g. for per-install identifiers.
String randomHexId({int bytes = 16}) {
final random = Random.secure();
return List<int>.generate(
bytes,
(_) => random.nextInt(256),
).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
+2 -2
View File
@@ -2,7 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../model/account_data.dart'; import '../../session/session_manager.dart';
import '../../theming/light_app_theme.dart'; import '../../theming/light_app_theme.dart';
import '../../widget/app_progress_indicator.dart'; import '../../widget/app_progress_indicator.dart';
@@ -61,7 +61,7 @@ class _AccountLoadingScreenState extends State<AccountLoadingScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextButton( TextButton(
onPressed: AccountData().abandonLoad, onPressed: SessionManager().abandonLoad,
style: TextButton.styleFrom(foregroundColor: Colors.white), style: TextButton.styleFrom(foregroundColor: Colors.white),
child: const Text('Zur Anmeldung'), child: const Text('Zur Anmeldung'),
), ),
@@ -0,0 +1,196 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import '../../api/demo/demo_mode.dart';
import '../../api/errors/error_mapper.dart';
import '../../api/marianumconnect/auth/device_token_name.dart';
import '../../api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart';
import '../../api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart';
import '../../api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart';
import '../../auth_link/device_binding.dart';
import '../../auth_link/guardian_login_link.dart';
import '../../auth_link/pending_guardian_request.dart';
import '../../session/session.dart';
import '../../session/session_manager.dart';
import '../../widget_data/widget_sync.dart';
enum GuardianLoginStep { enterEmail, enterCode }
/// Drives the passwordless guardian login: request a mail, then finish with
/// the mailed code or link. The running request is persisted so the flow
/// survives the app being killed while the user reads the mail.
class GuardianLoginController extends ChangeNotifier {
final AuthGuardianRequest _request;
final AuthGuardianVerify _verify;
final PendingGuardianRequestStore _store;
final Future<void> Function(Session session) _signIn;
final Future<String> Function() _tokenName;
final DateTime Function() _now;
GuardianLoginController({
AuthGuardianRequest? request,
AuthGuardianVerify? verify,
PendingGuardianRequestStore store = const PendingGuardianRequestStore(),
Future<void> Function(Session session)? signIn,
Future<String> Function()? tokenName,
DateTime Function()? now,
}) : _request = request ?? AuthGuardianRequest(),
_verify = verify ?? AuthGuardianVerify(),
_store = store,
_signIn = signIn ?? _defaultSignIn,
_tokenName = tokenName ?? DeviceTokenName.resolve,
_now = now ?? DateTime.now;
GuardianLoginStep _step = GuardianLoginStep.enterEmail;
PendingGuardianRequest? _pending;
bool _loading = false;
String? _errorMessage;
String? _errorDetails;
GuardianLoginStep get step => _step;
PendingGuardianRequest? get pending => _pending;
bool get loading => _loading;
String? get errorMessage => _errorMessage;
String? get errorDetails => _errorDetails;
bool canResend() {
final pending = _pending;
return pending != null && !_now().isBefore(pending.resendAvailableAt);
}
/// Picks up a request started before the app was closed.
Future<void> restore() async {
final stored = await _store.read();
if (stored == null) return;
if (stored.isExpired(_now())) {
await _store.clear();
return;
}
_pending = stored;
_step = GuardianLoginStep.enterCode;
notifyListeners();
}
/// Sends the login mail. Returns true when the user is already signed in
/// (demo address), false when the code step follows or the request failed.
Future<bool> requestCode(String email) async {
final normalized = email.trim().toLowerCase();
if (DemoMode.matchesGuardian(normalized)) {
await _signIn(GuardianSession(email: normalized, isDemo: true));
return true;
}
await _run(() async {
final secret = DeviceBinding.generateSecret();
final response = await _request.run(
email: normalized,
deviceChallenge: DeviceBinding.challengeFor(secret),
tokenName: await _tokenName(),
);
final pending = PendingGuardianRequest(
requestId: response.requestId,
email: normalized,
deviceSecret: secret,
expiresAt: response.expiresAt,
resendAvailableAt: response.resendAvailableAt,
codeLength: response.codeLength,
);
await _store.write(pending);
_pending = pending;
_step = GuardianLoginStep.enterCode;
});
return false;
}
Future<void> resend() async {
final pending = _pending;
if (pending == null || !canResend()) return;
await requestCode(pending.email);
}
/// Mail clients and autofill may insert spaces into the code.
static String normalizeCode(String code) =>
code.replaceAll(RegExp(r'\s'), '');
Future<bool> submitCode(String code) => _complete(code: normalizeCode(code));
/// Completes the login from a mail link. A link belonging to another
/// request (other device, or an older mail) cannot be verified here.
Future<bool> submitLink(GuardianLoginLink link) {
if (_pending?.requestId != link.requestId) {
_errorMessage = GuardianLoginException.messageFor(
GuardianLoginError.deviceMismatch,
);
_errorDetails = null;
notifyListeners();
return Future.value(false);
}
return _complete(linkToken: link.linkToken);
}
/// Abandons the running request, e.g. to correct a mistyped address.
Future<void> changeEmail() async {
await _store.clear();
_pending = null;
_step = GuardianLoginStep.enterEmail;
_errorMessage = null;
_errorDetails = null;
notifyListeners();
}
Future<bool> _complete({String? code, String? linkToken}) async {
final pending = _pending;
if (pending == null) return false;
var signedIn = false;
await _run(() async {
await _verify.run(
requestId: pending.requestId,
deviceVerifier: pending.deviceSecret,
tokenName: await _tokenName(),
code: code,
linkToken: linkToken,
);
await _store.clear();
await _signIn(GuardianSession(email: pending.email));
signedIn = true;
});
return signedIn;
}
Future<void> _run(Future<void> Function() body) async {
if (_loading) return;
_loading = true;
_errorMessage = null;
_errorDetails = null;
notifyListeners();
try {
await body();
} on GuardianLoginException catch (e) {
_errorMessage = e.userMessage;
_errorDetails = e.technicalDetails;
// These end the request for good; only a new mail helps.
if (e.error
case GuardianLoginError.requestExpired ||
GuardianLoginError.requestConsumed ||
GuardianLoginError.tooManyAttempts) {
await _store.clear();
_pending = null;
_step = GuardianLoginStep.enterEmail;
}
} catch (e) {
log('Guardian login failed: $e');
_errorMessage = errorToUserMessage(e);
_errorDetails = errorToTechnicalDetails(e);
} finally {
_loading = false;
notifyListeners();
}
}
static Future<void> _defaultSignIn(Session session) async {
// Drop any widget snapshot of a previous account before the new one loads.
await WidgetSync.clear();
await WidgetSync.triggerUpdate();
await SessionManager().signIn(session);
}
}
+81 -5
View File
@@ -3,6 +3,9 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../api/marianumconnect/marianumconnect_endpoint.dart' as mc;
import '../../auth_link/guardian_link_listener.dart';
import '../../auth_link/guardian_login_link.dart';
import '../../background/widget_background_task.dart'; import '../../background/widget_background_task.dart';
import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart';
import '../../state/app/modules/account/bloc/account_state.dart'; import '../../state/app/modules/account/bloc/account_state.dart';
@@ -12,8 +15,11 @@ import '../../storage/settings.dart' as model;
import '../../theming/light_app_theme.dart'; import '../../theming/light_app_theme.dart';
import '../../utils/haptics.dart'; import '../../utils/haptics.dart';
import '../pages/settings/widgets/endpoint_picker.dart'; import '../pages/settings/widgets/endpoint_picker.dart';
import 'guardian_login_controller.dart';
import 'login_controller.dart'; import 'login_controller.dart';
import 'post_login_splash.dart'; import 'post_login_splash.dart';
import 'widgets/guardian_login_card.dart';
import 'widgets/login_audience_card.dart';
import 'widgets/login_branding.dart'; import 'widgets/login_branding.dart';
import 'widgets/login_card.dart'; import 'widgets/login_card.dart';
@@ -28,12 +34,45 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
static const _marianumRed = LightAppTheme.marianumRed; static const _marianumRed = LightAppTheme.marianumRed;
final LoginController _controller = LoginController(); final LoginController _controller = LoginController();
final GuardianLoginController _guardianController = GuardianLoginController();
late final Future<void> _guardianRestored;
/// Null while the user has not picked who is signing in.
LoginAudience? _audience;
late final AnimationController _fade = AnimationController( late final AnimationController _fade = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 450), duration: const Duration(milliseconds: 450),
value: 1, value: 1,
); );
@override
void initState() {
super.initState();
_guardianRestored = _guardianController.restore().then((_) {
if (!mounted || _guardianController.pending == null) return;
setState(() => _audience = LoginAudience.guardian);
});
GuardianLinkListener.pending.addListener(_consumeGuardianLink);
_consumeGuardianLink();
}
/// A tapped mail link finishes the guardian login without typing the code.
Future<void> _consumeGuardianLink() async {
final uri = GuardianLinkListener.pending.value;
if (uri == null) return;
GuardianLinkListener.clear();
final link = GuardianLoginLink.parse(
uri,
apiBase: Uri.parse(mc.MarianumConnectEndpoint.current()),
);
if (link == null) return;
await _guardianRestored;
if (!mounted) return;
setState(() => _audience = LoginAudience.guardian);
final signedIn = await _guardianController.submitLink(link);
if (signedIn && mounted) _onLoginSuccess();
}
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@@ -43,8 +82,10 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
@override @override
void dispose() { void dispose() {
GuardianLinkListener.pending.removeListener(_consumeGuardianLink);
_fade.dispose(); _fade.dispose();
_controller.dispose(); _controller.dispose();
_guardianController.dispose();
super.dispose(); super.dispose();
} }
@@ -64,6 +105,20 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
}); });
} }
Widget _buildCard() => switch (_audience) {
null => LoginAudienceCard(
onSelected: (choice) => setState(() => _audience = choice),
),
LoginAudience.school => LoginCard(
controller: _controller,
onSuccess: _onLoginSuccess,
),
LoginAudience.guardian => GuardianLoginCard(
controller: _guardianController,
onSuccess: _onLoginSuccess,
),
};
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
backgroundColor: _marianumRed, backgroundColor: _marianumRed,
@@ -89,12 +144,33 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
children: [ children: [
const LoginHeader(), const LoginHeader(),
const SizedBox(height: 28), const SizedBox(height: 28),
LoginCard( _buildCard(),
controller: _controller, if (_audience != null)
onSuccess: _onLoginSuccess, Padding(
padding: const EdgeInsets.only(top: 8),
// Leaving mid-request would drop the form that
// receives the result, so it is disabled then.
child: ListenableBuilder(
listenable: Listenable.merge([
_controller,
_guardianController,
]),
builder: (context, _) => TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: Colors.white,
), ),
const SizedBox(height: 18), icon: const Icon(Icons.arrow_back, size: 18),
const LoginDisclaimer(), label: const Text('Zurück zur Auswahl'),
onPressed:
_controller.loading ||
_guardianController.loading
? null
: () => setState(() => _audience = null),
),
),
)
else
const SizedBox(height: 12),
], ],
), ),
const Column( const Column(
+23 -18
View File
@@ -10,7 +10,8 @@ import '../../api/marianumconnect/auth/device_token_name.dart';
import '../../api/marianumconnect/auth/token_storage.dart'; import '../../api/marianumconnect/auth/token_storage.dart';
import '../../api/marianumconnect/queries/auth_login/auth_login.dart'; import '../../api/marianumconnect/queries/auth_login/auth_login.dart';
import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart'; import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
import '../../model/account_data.dart'; import '../../session/session.dart';
import '../../session/session_manager.dart';
import '../../widget_data/widget_sync.dart'; import '../../widget_data/widget_sync.dart';
/// Outcome of a login attempt. /// Outcome of a login attempt.
@@ -51,25 +52,17 @@ class LoginController extends ChangeNotifier {
// Demo login: the prefix enters local demo mode, password ignored, no // Demo login: the prefix enters local demo mode, password ignored, no
// network (see DemoMode). // network (see DemoMode).
if (DemoMode.matches(user)) { if (DemoMode.matches(user)) {
await AccountData().removeData(); await _discardPreviousAccount();
await const MarianumConnectTokenStorage().clear(); await SessionManager().signIn(
await WidgetSync.clear(); CredentialSession(username: user, password: 'demo', isDemo: true),
await WidgetSync.triggerUpdate(); );
await AccountData().setDemo(user);
_loading = false; _loading = false;
notifyListeners(); notifyListeners();
return LoginResult.success; return LoginResult.success;
} }
try { try {
await AccountData().removeData(); await _discardPreviousAccount();
// Vorherigen Token revoken bevor wir einen neuen anfordern — ein altes
// Account hätte sonst noch einen aktiven Token in api_tokens.
await const MarianumConnectTokenStorage().clear();
// Widget-Snapshot löschen, sonst blitzt nach Account-Wechsel kurz der
// Stundenplan des vorigen Users auf dem Home-Bildschirm.
await WidgetSync.clear();
await WidgetSync.triggerUpdate();
// AuthLogin = Credential-Probe + Token-Create in einem Call. // AuthLogin = Credential-Probe + Token-Create in einem Call.
// 401 hier heißt: falsches Passwort. // 401 hier heißt: falsches Passwort.
await AuthLogin().run( await AuthLogin().run(
@@ -77,7 +70,9 @@ class LoginController extends ChangeNotifier {
password: password, password: password,
tokenName: await DeviceTokenName.resolve(), tokenName: await DeviceTokenName.resolve(),
); );
await AccountData().setData(user, password); await SessionManager().signIn(
CredentialSession(username: user, password: password),
);
// Mint the Nextcloud app password now — it doubles as the Nextcloud // Mint the Nextcloud app password now — it doubles as the Nextcloud
// credential probe: a rejection means 2FA is active (or the NC password // credential probe: a rejection means 2FA is active (or the NC password
// diverges) and the login must finish interactively in the browser. // diverges) and the login must finish interactively in the browser.
@@ -87,7 +82,7 @@ class LoginController extends ChangeNotifier {
return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired; return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired;
} catch (e) { } catch (e) {
log(e.toString()); log(e.toString());
await AccountData().removeData(); await SessionManager().signOut();
await const MarianumConnectTokenStorage().clear(); await const MarianumConnectTokenStorage().clear();
final isWrongCredentials = e is AuthException && e.statusCode == 401; final isWrongCredentials = e is AuthException && e.statusCode == 401;
_errorMessage = isWrongCredentials _errorMessage = isWrongCredentials
@@ -100,6 +95,16 @@ class LoginController extends ChangeNotifier {
} }
} }
/// Vorherigen Token verwerfen, bevor ein neuer angefordert wird, und den
/// Widget-Snapshot löschen — sonst blitzt nach einem Account-Wechsel kurz
/// der Stundenplan des vorigen Users auf dem Home-Bildschirm. Die Session
/// selbst überschreibt signIn vollständig.
Future<void> _discardPreviousAccount() async {
await const MarianumConnectTokenStorage().clear();
await WidgetSync.clear();
await WidgetSync.triggerUpdate();
}
/// Tries to mint the Nextcloud app password with the just-verified password. /// Tries to mint the Nextcloud app password with the just-verified password.
/// `false` = Nextcloud rejected the credentials → Login Flow v2 required. /// `false` = Nextcloud rejected the credentials → Login Flow v2 required.
/// Transport/server problems stay non-blocking (like the previous /// Transport/server problems stay non-blocking (like the previous
@@ -107,7 +112,7 @@ class LoginController extends ChangeNotifier {
Future<bool> _prepareNextcloudAppPassword() async { Future<bool> _prepareNextcloudAppPassword() async {
try { try {
final appPassword = await GetAppPassword().run(); final appPassword = await GetAppPassword().run();
await AccountData().setAppPassword(appPassword); await SessionManager().setAppPassword(appPassword);
return true; return true;
} on AuthException { } on AuthException {
return false; return false;
@@ -126,7 +131,7 @@ class LoginController extends ChangeNotifier {
} on Object catch (e) { } on Object catch (e) {
log('Login rollback: MC logout failed: $e'); log('Login rollback: MC logout failed: $e');
} }
await AccountData().removeData(); await SessionManager().signOut();
await const MarianumConnectTokenStorage().clear(); await const MarianumConnectTokenStorage().clear();
_errorMessage = _errorMessage =
'Die Anmeldung wurde abgebrochen — dein Konto erfordert die Bestätigung im Browser.'; 'Die Anmeldung wurde abgebrochen — dein Konto erfordert die Bestätigung im Browser.';
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
import '../../api/errors/error_mapper.dart'; import '../../api/errors/error_mapper.dart';
import '../../api/marianumcloud/app_password/delete_app_password.dart'; import '../../api/marianumcloud/app_password/delete_app_password.dart';
import '../../api/marianumcloud/login_flow/login_flow_api.dart'; import '../../api/marianumcloud/login_flow/login_flow_api.dart';
import '../../model/account_data.dart'; import '../../session/session_manager.dart';
import '../../utils/url_opener.dart'; import '../../utils/url_opener.dart';
import '../../widget/app_progress_indicator.dart'; import '../../widget/app_progress_indicator.dart';
@@ -19,7 +19,7 @@ enum _FlowStep { primary, talk }
/// Runs the Nextcloud Login Flow v2: opens the browser login, polls until the /// 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 /// user confirmed it there (2FA happens inside the browser) and adopts the
/// returned app password via [AccountData.setLoginFlow]. A second, skippable /// returned app password via [SessionManager.setLoginFlow]. A second, skippable
/// pass mints the Talk app password so flow accounts keep BOTH push /// pass mints the Talk app password so flow accounts keep BOTH push
/// subscriptions (see PushRegistrationType). Pops `true` once the primary /// subscriptions (see PushRegistrationType). Pops `true` once the primary
/// credential was adopted, `false`/`null` when the user backs out before that. /// credential was adopted, `false`/`null` when the user backs out before that.
@@ -104,7 +104,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
final credentials = await _api.poll(flow); final credentials = await _api.poll(flow);
if (credentials == null || _finished || !mounted) return; if (credentials == null || _finished || !mounted) return;
if (!LoginFlowApi.loginNameMatches( if (!LoginFlowApi.loginNameMatches(
expected: AccountData().getUsername(), expected: SessionManager().requireNextcloud().username,
actual: credentials.loginName, actual: credentials.loginName,
)) { )) {
_timer?.cancel(); _timer?.cancel();
@@ -120,7 +120,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
} }
switch (_step) { switch (_step) {
case _FlowStep.primary: case _FlowStep.primary:
await AccountData().setLoginFlow(credentials.appPassword); await SessionManager().setLoginFlow(credentials.appPassword);
if (!mounted) return; if (!mounted) return;
// Zweite Freigabe direkt anstoßen: die Browser-Session besteht // Zweite Freigabe direkt anstoßen: die Browser-Session besteht
// bereits, es fehlt nur noch der Grant-Tipp. // bereits, es fehlt nur noch der Grant-Tipp.
@@ -129,7 +129,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
case _FlowStep.talk: case _FlowStep.talk:
_finished = true; _finished = true;
_timer?.cancel(); _timer?.cancel();
await AccountData().setAppPasswordTalk(credentials.appPassword); await SessionManager().setAppPasswordTalk(credentials.appPassword);
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
} }
@@ -0,0 +1,225 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../guardian_login_controller.dart';
import 'login_error_banner.dart';
import 'login_form_parts.dart';
/// Passwordless guardian login: e-mail step, then the mailed six-digit code.
/// A tapped mail link completes the second step without typing (handled by
/// the login screen).
class GuardianLoginCard extends StatefulWidget {
final GuardianLoginController controller;
final VoidCallback onSuccess;
const GuardianLoginCard({
required this.controller,
required this.onSuccess,
super.key,
});
@override
State<GuardianLoginCard> createState() => _GuardianLoginCardState();
}
class _GuardianLoginCardState extends State<GuardianLoginCard> {
final _emailFormKey = GlobalKey<FormState>();
final _codeFormKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _codeController = TextEditingController();
Timer? _resendTicker;
GuardianLoginController get _controller => widget.controller;
@override
void initState() {
super.initState();
_controller.addListener(_onControllerChange);
_syncResendTicker();
}
@override
void dispose() {
_controller.removeListener(_onControllerChange);
_resendTicker?.cancel();
_emailController.dispose();
_codeController.dispose();
super.dispose();
}
void _onControllerChange() {
if (!mounted) return;
_syncResendTicker();
setState(() {});
}
// Rebuilds once per second while the resend cooldown runs so the countdown
// stays current.
void _syncResendTicker() {
final waiting =
_controller.step == GuardianLoginStep.enterCode &&
!_controller.canResend();
if (waiting && _resendTicker == null) {
_resendTicker = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() {});
if (_controller.canResend()) {
_resendTicker?.cancel();
_resendTicker = null;
}
});
} else if (!waiting) {
_resendTicker?.cancel();
_resendTicker = null;
}
}
String? _validateEmail(String? value) {
final email = (value ?? '').trim();
if (email.isEmpty) return 'Eingabe erforderlich';
if (!RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email)) {
return 'Bitte eine gültige E-Mail-Adresse eingeben';
}
return null;
}
String? _validateCode(String? value) {
final length = _controller.pending!.codeLength;
final code = GuardianLoginController.normalizeCode(value ?? '');
return code.length == length
? null
: 'Bitte den $length-stelligen Code eingeben';
}
Future<void> _requestCode() async {
if (_controller.loading) return;
if (!(_emailFormKey.currentState?.validate() ?? false)) return;
final signedIn = await _controller.requestCode(_emailController.text);
if (signedIn && mounted) widget.onSuccess();
}
Future<void> _submitCode() async {
if (_controller.loading) return;
if (!(_codeFormKey.currentState?.validate() ?? false)) return;
final signedIn = await _controller.submitCode(_codeController.text);
if (signedIn && mounted) widget.onSuccess();
}
@override
Widget build(BuildContext context) => switch (_controller.step) {
GuardianLoginStep.enterEmail => _buildEmailStep(context),
GuardianLoginStep.enterCode => _buildCodeStep(context),
};
Widget _buildEmailStep(BuildContext context) {
final theme = Theme.of(context);
return Form(
key: _emailFormKey,
child: LoginCardFrame(
title: 'Anmeldung für Eltern',
hint:
'Gib die E-Mail-Adresse ein, die bei der Schule hinterlegt ist. '
'Du erhältst einen Anmeldecode per E-Mail.',
children: [
TextFormField(
key: const Key('guardian-email-field'),
controller: _emailController,
enabled: !_controller.loading,
validator: _validateEmail,
autocorrect: false,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _requestCode(),
decoration: loginInputDecoration(
theme,
'E-Mail-Adresse',
Icons.alternate_email,
),
),
LoginErrorBanner(
message: _controller.errorMessage,
details: _controller.errorDetails,
),
const SizedBox(height: 20),
LoginSubmitButton(
key: const Key('guardian-request-button'),
label: 'Code anfordern',
loading: _controller.loading,
onPressed: _requestCode,
),
],
),
);
}
Widget _buildCodeStep(BuildContext context) {
final theme = Theme.of(context);
final pending = _controller.pending!;
final remaining = pending.resendAvailableAt.difference(DateTime.now());
return Form(
key: _codeFormKey,
child: LoginCardFrame(
title: 'Code eingeben',
hint:
'Wir haben eine E-Mail an ${pending.email} gesendet. Gib den Code '
'ein oder tippe auf den Link in der E-Mail.',
children: [
TextFormField(
key: const Key('guardian-code-field'),
controller: _codeController,
enabled: !_controller.loading,
validator: _validateCode,
autofocus: true,
keyboardType: TextInputType.number,
autofillHints: const [AutofillHints.oneTimeCode],
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(pending.codeLength),
],
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submitCode(),
decoration: loginInputDecoration(
theme,
'Anmeldecode',
Icons.pin_outlined,
),
),
LoginErrorBanner(
message: _controller.errorMessage,
details: _controller.errorDetails,
),
const SizedBox(height: 20),
LoginSubmitButton(
key: const Key('guardian-verify-button'),
label: 'Anmelden',
loading: _controller.loading,
onPressed: _submitCode,
),
const SizedBox(height: 8),
Row(
children: [
TextButton(
onPressed: _controller.loading ? null : _controller.changeEmail,
child: const Text('E-Mail ändern'),
),
const Spacer(),
TextButton(
onPressed: _controller.loading || !_controller.canResend()
? null
: _controller.resend,
child: Text(
_controller.canResend()
? 'Erneut senden'
: 'Erneut senden (${remaining.inSeconds + 1} s)',
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'login_form_parts.dart';
enum LoginAudience { school, guardian }
/// First login step: who is signing in. School accounts and guardians use
/// entirely different forms, so the choice comes before any input.
class LoginAudienceCard extends StatelessWidget {
final ValueChanged<LoginAudience> onSelected;
const LoginAudienceCard({required this.onSelected, super.key});
@override
Widget build(BuildContext context) => LoginCardFrame(
title: 'Anmelden',
hint: 'Bite wähle deine Anmeldemethode',
children: [
_AudienceButton(
key: const Key('login-audience-school'),
icon: Icons.school_outlined,
label: 'Login für Schülerschaft & Lehrkräfte',
onPressed: () => onSelected(LoginAudience.school),
),
const SizedBox(height: 12),
_AudienceButton(
key: const Key('login-audience-guardian'),
icon: Icons.family_restroom_outlined,
label: 'Login für Eltern',
onPressed: () => onSelected(LoginAudience.guardian),
),
],
);
}
class _AudienceButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onPressed;
const _AudienceButton({
required this.icon,
required this.label,
required this.onPressed,
super.key,
});
@override
Widget build(BuildContext context) => SizedBox(
height: 64,
child: FilledButton.tonalIcon(
onPressed: onPressed,
icon: Icon(icon, size: 26),
label: Text(label),
style: FilledButton.styleFrom(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
);
}
+1 -19
View File
@@ -29,7 +29,7 @@ class LoginHeader extends StatelessWidget {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Stundenplan, Talk & Dateien an einem Ort.', 'Stundenplan, Talk, Dateien und mehr - alles an einem Ort für deinen Schulalltag am Marianum Fulda.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: Colors.white.withValues(alpha: 0.85), color: Colors.white.withValues(alpha: 0.85),
@@ -41,24 +41,6 @@ class LoginHeader extends StatelessWidget {
); );
} }
class LoginDisclaimer extends StatelessWidget {
const LoginDisclaimer({super.key});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Alles für deinen Schulalltag am Marianum Fulda.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.75),
fontSize: 11,
height: 1.4,
),
),
);
}
class LoginFooter extends StatelessWidget { class LoginFooter extends StatelessWidget {
const LoginFooter({super.key}); const LoginFooter({super.key});
+15 -74
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../../routing/app_routes.dart'; import '../../../routing/app_routes.dart';
import '../login_controller.dart'; import '../login_controller.dart';
import 'login_error_banner.dart'; import 'login_error_banner.dart';
import 'login_form_parts.dart';
/// White Card hosting the login form (heading, two text fields, error /// White Card hosting the login form (heading, two text fields, error
/// banner, submit button). Submitting calls [controller.submit] and signals /// banner, submit button). Submitting calls [controller.submit] and signals
@@ -75,58 +76,16 @@ class _LoginCardState extends State<LoginCard> {
} }
} }
InputDecoration _decoration(ThemeData theme, String label, IconData icon) =>
InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
filled: true,
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.4,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5),
),
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final loading = widget.controller.loading; final loading = widget.controller.loading;
return Card( return Form(
elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.35),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
color: theme.colorScheme.surface,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
child: Form(
key: _formKey, key: _formKey,
child: Column( child: LoginCardFrame(
crossAxisAlignment: CrossAxisAlignment.stretch, title: 'Anmelden',
hint: 'Melde dich mit deinen Marianum-Zugangsdaten an.',
children: [ children: [
Text(
'Anmelden',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
'Melde dich mit deinen Marianum-Zugangsdaten an.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
TextFormField( TextFormField(
key: const Key('login-username-field'), key: const Key('login-username-field'),
controller: _usernameController, controller: _usernameController,
@@ -135,7 +94,7 @@ class _LoginCardState extends State<LoginCard> {
autocorrect: false, autocorrect: false,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _passwordFocus.requestFocus(), onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
decoration: _decoration( decoration: loginInputDecoration(
theme, theme,
'Nutzername', 'Nutzername',
Icons.person_outline, Icons.person_outline,
@@ -155,43 +114,25 @@ class _LoginCardState extends State<LoginCard> {
keyboardType: TextInputType.visiblePassword, keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(), onFieldSubmitted: (_) => _submit(),
decoration: _decoration(theme, 'Passwort', Icons.lock_outline), decoration: loginInputDecoration(
theme,
'Passwort',
Icons.lock_outline,
),
), ),
LoginErrorBanner( LoginErrorBanner(
message: widget.controller.errorMessage, message: widget.controller.errorMessage,
details: widget.controller.errorDetails, details: widget.controller.errorDetails,
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
SizedBox( LoginSubmitButton(
height: 50,
child: FilledButton(
key: const Key('login-submit-button'), key: const Key('login-submit-button'),
onPressed: loading ? null : _submit, label: 'Anmelden',
style: FilledButton.styleFrom( loading: loading,
shape: RoundedRectangleBorder( onPressed: _submit,
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
child: loading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: const Text('Anmelden'),
),
), ),
], ],
), ),
),
),
); );
} }
} }
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
/// Filled, borderless text field look shared by both login cards.
InputDecoration loginInputDecoration(
ThemeData theme,
String label,
IconData icon,
) => InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
filled: true,
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5),
),
);
/// Card frame with heading and hint line shared by both login cards.
class LoginCardFrame extends StatelessWidget {
final String title;
final String hint;
final List<Widget> children;
const LoginCardFrame({
required this.title,
required this.hint,
required this.children,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.35),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
color: theme.colorScheme.surface,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
hint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
...children,
],
),
),
);
}
}
/// Full-width primary button that swaps its label for a spinner while busy.
class LoginSubmitButton extends StatelessWidget {
final String label;
final bool loading;
final VoidCallback onPressed;
const LoginSubmitButton({
required this.label,
required this.loading,
required this.onPressed,
super.key,
});
@override
Widget build(BuildContext context) => SizedBox(
height: 50,
child: FilledButton(
onPressed: loading ? null : onPressed,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
child: loading
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Text(label),
),
);
}
@@ -0,0 +1,28 @@
import '../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../../../session/session.dart';
import '../../../state/app/modules/children/child_selection_cubit.dart';
/// Who an absence report is filed for, and what the form lets the user edit.
class AbsenceFormPolicy {
/// The child the report is for; null when users report for themselves.
final GuardianChild? child;
const AbsenceFormPolicy._(this.child);
/// Guardians report for a linked child whose identity the server knows,
/// so name and class are fixed.
bool get identityEditable => child == null;
/// Null when the session cannot file a report (guardian without children).
static AbsenceFormPolicy? resolve({
required Session? session,
required List<GuardianChild> children,
required String? selectedChildId,
}) => switch (session) {
GuardianSession() => switch (effectiveChild(children, selectedChildId)) {
null => null,
final child => AbsenceFormPolicy._(child),
},
_ => const AbsenceFormPolicy._(null),
};
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../api/errors/error_mapper.dart'; import '../../../api/errors/error_mapper.dart';
import '../../../api/marianumconnect/queries/absence/absence_classes.dart'; import '../../../api/marianumconnect/queries/absence/absence_classes.dart';
@@ -6,24 +7,55 @@ import '../../../api/marianumconnect/queries/absence/absence_prefill.dart';
import '../../../api/marianumconnect/queries/absence/absence_prefill_response.dart'; import '../../../api/marianumconnect/queries/absence/absence_prefill_response.dart';
import '../../../api/marianumconnect/queries/absence/absence_submit.dart'; import '../../../api/marianumconnect/queries/absence/absence_submit.dart';
import '../../../extensions/date_time.dart'; import '../../../extensions/date_time.dart';
import '../../../session/session_manager.dart';
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../../state/app/modules/children/child_selection_cubit.dart';
import '../../../widget/app_progress_indicator.dart'; import '../../../widget/app_progress_indicator.dart';
import '../../../widget/async_action_button.dart'; import '../../../widget/async_action_button.dart';
import '../../../widget/child_switcher.dart';
import '../../../widget/demo_restricted.dart'; import '../../../widget/demo_restricted.dart';
import '../../../widget/focus_behaviour.dart'; import '../../../widget/focus_behaviour.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
import 'absence_form_policy.dart';
/// Mobile mirror of the public absence-report form: submit-only (no history — /// Mobile mirror of the public absence-report form: submit-only (no history —
/// that lives on the web). Identity/class/phone are prefilled from the backend /// that lives on the web). Identity/class/phone are prefilled from the backend
/// but stay editable; the class list matches the submit validation source. /// but stay editable; the class list matches the submit validation source.
/// Guardians report for the selected child, whose identity is fixed.
/// User-facing texts mirror the public web form (`AbsencePublicForm.svelte`). /// User-facing texts mirror the public web form (`AbsencePublicForm.svelte`).
class AbsenceReportView extends StatefulWidget { class AbsenceReportView extends StatelessWidget {
const AbsenceReportView({super.key}); const AbsenceReportView({super.key});
@override @override
State<AbsenceReportView> createState() => _AbsenceReportViewState(); Widget build(BuildContext context) {
final policy = AbsenceFormPolicy.resolve(
session: SessionManager().current,
children: context.watch<CapabilitiesCubit>().state.children,
selectedChildId: context.watch<ChildSelectionCubit>().state,
);
return Scaffold(
appBar: AppBar(
title: const Text('Abwesenheitsmeldung'),
actions: const [ChildSwitcher()],
),
body: policy == null
? const NoChildrenPlaceholder()
// Re-created per child so no input leaks into another child's report.
: _AbsenceForm(key: ValueKey(policy.child?.id), policy: policy),
);
}
} }
class _AbsenceReportViewState extends State<AbsenceReportView> { class _AbsenceForm extends StatefulWidget {
final AbsenceFormPolicy policy;
const _AbsenceForm({required this.policy, super.key});
@override
State<_AbsenceForm> createState() => _AbsenceFormState();
}
class _AbsenceFormState extends State<_AbsenceForm> {
static const String _required = 'Dieses Feld ist erforderlich.'; static const String _required = 'Dieses Feld ist erforderlich.';
final TextEditingController _firstName = TextEditingController(); final TextEditingController _firstName = TextEditingController();
@@ -67,7 +99,17 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
if (_submitted) setState(() {}); if (_submitted) setState(() {});
} }
String? get _childId => widget.policy.child?.id;
Future<void> _load() async { Future<void> _load() async {
if (!widget.policy.identityEditable) {
// The child's identity is the whole point of the form, so a failed
// prefill is an error here, not a degraded start.
final prefill = await AbsencePrefill().run(childId: _childId);
_classes = [prefill.className];
_applyPrefill(prefill, _classes);
return;
}
// Both GETs are independent — fire them together. Prefill is best-effort // Both GETs are independent — fire them together. Prefill is best-effort
// (mapped to null on failure), so a classes error still propagates while a // (mapped to null on failure), so a classes error still propagates while a
// prefill failure never surfaces as an unhandled async error. // prefill failure never surfaces as an unhandled async error.
@@ -148,6 +190,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
absentUntil: _absentUntil, absentUntil: _absentUntil,
phone: _phone.text.trim(), phone: _phone.text.trim(),
note: _note.text.trim(), note: _note.text.trim(),
childId: _childId,
); );
if (!mounted) return; if (!mounted) return;
// Replace the whole form with a terminal success screen. There is // Replace the whole form with a terminal success screen. There is
@@ -157,10 +200,8 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
} }
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) =>
appBar: AppBar(title: const Text('Abwesenheitsmeldung')), _done ? const _SubmittedView() : _buildBody(context);
body: _done ? const _SubmittedView() : _buildBody(context),
);
Widget _buildBody(BuildContext context) => FutureBuilder<void>( Widget _buildBody(BuildContext context) => FutureBuilder<void>(
future: _init, future: _init,
@@ -205,6 +246,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
const SizedBox(height: 20), const SizedBox(height: 20),
TextField( TextField(
controller: _firstName, controller: _firstName,
readOnly: !widget.policy.identityEditable,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
decoration: _decoration( decoration: _decoration(
'Vorname', 'Vorname',
@@ -215,6 +257,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _lastName, controller: _lastName,
readOnly: !widget.policy.identityEditable,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
decoration: _decoration( decoration: _decoration(
'Nachname', 'Nachname',
@@ -234,7 +277,9 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
items: _classes items: _classes
.map((c) => DropdownMenuItem(value: c, child: Text(c))) .map((c) => DropdownMenuItem(value: c, child: Text(c)))
.toList(), .toList(),
onChanged: (value) => setState(() => _selectedClass = value), onChanged: widget.policy.identityEditable
? (value) => setState(() => _selectedClass = value)
: null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_DateField( _DateField(
@@ -2,8 +2,8 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../model/account_data.dart';
import '../../../../model/endpoint_data.dart'; import '../../../../model/endpoint_data.dart';
import '../../../../session/session_manager.dart';
import '../data/file_type_icon.dart'; import '../data/file_type_icon.dart';
/// Leading slot for a file row: shows the Nextcloud thumbnail when the /// Leading slot for a file row: shows the Nextcloud thumbnail when the
@@ -35,7 +35,7 @@ class FileLeading extends StatelessWidget {
'https://${EndpointData().nextcloud().full()}' 'https://${EndpointData().nextcloud().full()}'
'/index.php/core/preview' '/index.php/core/preview'
'?fileId=$fileId&x=128&y=128&a=0', '?fileId=$fileId&x=128&y=128&a=0',
httpHeaders: AccountData().authHeaders(), httpHeaders: SessionManager().requireNextcloud().authHeaders,
fit: BoxFit.cover, fit: BoxFit.cover,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../timetable/custom_events/custom_event_edit_dialog.dart'; import '../../timetable/custom_events/custom_event_edit_dialog.dart';
import '../data/event_formatter.dart'; import '../data/event_formatter.dart';
import 'event_details_sheet.dart'; import 'event_details_sheet.dart';
@@ -89,6 +91,12 @@ class MarianumDateRow extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
// Custom events are private to the own plan; a guardian's plan
// belongs to the child.
if (context
.watch<TimetableBloc>()
.subject
.supportsCustomEvents) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
IconButton( IconButton(
icon: _CalendarPlusIcon( icon: _CalendarPlusIcon(
@@ -108,6 +116,7 @@ class MarianumDateRow extends StatelessWidget {
), ),
), ),
], ],
],
), ),
), ),
); );
@@ -4,15 +4,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart'; import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart';
import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
import '../../../../model/account_data.dart';
import '../../../../push/push_registration.dart'; import '../../../../push/push_registration.dart';
import '../../../../routing/app_routes.dart'; import '../../../../routing/app_routes.dart';
import '../../../../session/session.dart';
import '../../../../session/session_lifecycle.dart';
import '../../../../session/session_manager.dart';
import '../../../../state/app/modules/account/bloc/account_bloc.dart'; import '../../../../state/app/modules/account/bloc/account_bloc.dart';
import '../../../../state/app/modules/account/bloc/account_state.dart'; import '../../../../state/app/modules/account/bloc/account_state.dart';
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/async_action_button.dart'; import '../../../../widget/async_action_button.dart';
import '../../../../widget/avatar_actions_sheet.dart'; import '../../../../widget/avatar_actions_sheet.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/child_switcher.dart';
import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/demo_restricted.dart'; import '../../../../widget/demo_restricted.dart';
import '../../../../widget/user_avatar.dart'; import '../../../../widget/user_avatar.dart';
@@ -21,14 +25,53 @@ import '../../../../widget/user_avatar.dart';
// every Settings rebuild doesn't re-issue the OCS request. // every Settings rebuild doesn't re-issue the OCS request.
String? _cachedDisplayName; String? _cachedDisplayName;
class AccountSection extends StatefulWidget { class AccountSection extends StatelessWidget {
const AccountSection({super.key}); const AccountSection({super.key});
@override @override
State<AccountSection> createState() => _AccountSectionState(); Widget build(BuildContext context) => switch (SessionManager().current) {
GuardianSession(:final email) => _GuardianAccount(email: email),
_ => const _SchoolAccount(),
};
} }
class _AccountSectionState extends State<AccountSection> { /// Guardians have no Nextcloud profile: show the e-mail and linked children.
class _GuardianAccount extends StatelessWidget {
final String email;
const _GuardianAccount({required this.email});
@override
Widget build(BuildContext context) {
final children = context.watch<CapabilitiesCubit>().state.children;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
leading: const CenteredLeading(Icon(Icons.family_restroom_outlined)),
title: const Text('Elternkonto'),
subtitle: Text(email),
trailing: TextButton.icon(
icon: const Icon(Icons.logout_outlined, size: 18),
label: const Text('Abmelden'),
onPressed: () => _confirmLogout(context),
),
),
for (final child in children) ChildTile(child: child),
],
);
}
}
class _SchoolAccount extends StatefulWidget {
const _SchoolAccount();
@override
State<_SchoolAccount> createState() => _SchoolAccountState();
}
class _SchoolAccountState extends State<_SchoolAccount> {
int _avatarVersion = 0; int _avatarVersion = 0;
bool _avatarBusy = false; bool _avatarBusy = false;
String? _displayName = _cachedDisplayName; String? _displayName = _cachedDisplayName;
@@ -42,9 +85,7 @@ class _AccountSectionState extends State<AccountSection> {
Future<void> _loadDisplayName() async { Future<void> _loadDisplayName() async {
try { try {
final info = await GetUserInfo().run(); final info = await GetUserInfo().run();
_cachedDisplayName = info.displayName.isEmpty _cachedDisplayName = info.displayName.isEmpty ? null : info.displayName;
? null
: info.displayName;
if (!mounted) return; if (!mounted) return;
setState(() => _displayName = _cachedDisplayName); setState(() => _displayName = _cachedDisplayName);
} catch (_) { } catch (_) {
@@ -84,13 +125,17 @@ class _AccountSectionState extends State<AccountSection> {
setState(() => _avatarBusy = false); setState(() => _avatarBusy = false);
if (!ok) return; if (!ok) return;
invalidateAvatarCache(id: AccountData().getUsername(), isGroup: false); invalidateAvatarCache(
id: SessionManager().requireNextcloud().username,
isGroup: false,
);
setState(() => _avatarVersion++); setState(() => _avatarVersion++);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final username = AccountData().getUsername(); final nextcloud = SessionManager().requireNextcloud();
final username = nextcloud.username;
final displayName = _displayName; final displayName = _displayName;
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -109,8 +154,10 @@ class _AccountSectionState extends State<AccountSection> {
children: [ children: [
Center( Center(
child: GestureDetector( child: GestureDetector(
onTap: () => onTap: () => AppRoutes.openLargeProfilePicture(
AppRoutes.openLargeProfilePicture(context, username), context,
username,
),
child: UserAvatar( child: UserAvatar(
key: ValueKey(_avatarVersion), key: ValueKey(_avatarVersion),
id: username, id: username,
@@ -164,7 +211,7 @@ class _AccountSectionState extends State<AccountSection> {
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.logout_outlined, size: 18), icon: const Icon(Icons.logout_outlined, size: 18),
label: const Text('Abmelden'), label: const Text('Abmelden'),
onPressed: () => _showLogoutDialog(context), onPressed: () => _confirmLogout(context),
), ),
], ],
), ),
@@ -172,13 +219,11 @@ class _AccountSectionState extends State<AccountSection> {
// Nur für Login-Flow-Konten (2FA) sichtbar — Passwort-Konten heilen // Nur für Login-Flow-Konten (2FA) sichtbar — Passwort-Konten heilen
// sich still über das App-Passwort-Minting und sollen von dem ganzen // sich still über das App-Passwort-Minting und sollen von dem ganzen
// Flow-Mechanismus nichts mitbekommen. // Flow-Mechanismus nichts mitbekommen.
if (!AccountData().isDemo && AccountData().usesLoginFlow) if (!SessionManager().isDemo && nextcloud.usesLoginFlow)
AsyncListTile( AsyncListTile(
leading: const Icon(Icons.cloud_sync_outlined), leading: const Icon(Icons.cloud_sync_outlined),
title: const Text('Nextcloud neu verbinden'), title: const Text('Nextcloud neu verbinden'),
subtitle: const Text( subtitle: const Text('Bei Anmeldeproblemen in Talk oder Dateien'),
'Bei Anmeldeproblemen in Talk oder Dateien',
),
closeOnSuccess: false, closeOnSuccess: false,
onPressed: _reconnectNextcloud, onPressed: _reconnectNextcloud,
), ),
@@ -197,10 +242,11 @@ class _AccountSectionState extends State<AccountSection> {
const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')), const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')),
); );
} }
}
Future<void> _showLogoutDialog(BuildContext context) async { Future<void> _confirmLogout(BuildContext context) async {
// Flip AccountBloc state only after the dialog fully closes: doing it from // Flip AccountBloc state only after the dialog fully closes: doing it from
// inside removeData (the previous approach) raced AsyncDialogAction's // inside the sign-out (the previous approach) raced AsyncDialogAction's
// pop(true) against the listener's popUntil(isFirst) and could leave the // pop(true) against the listener's popUntil(isFirst) and could leave the
// navigator in an inconsistent state. // navigator in an inconsistent state.
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@@ -216,17 +262,10 @@ class _AccountSectionState extends State<AccountSection> {
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut); context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
} }
// Ordered teardown: unregister push at Nextcloud + proxy and revoke the app
// password (while Nextcloud credentials are still available), THEN revoke the
// MC bearer token, and finally wipe local credentials. Each step is
// best-effort so an offline logout still reaches a clean local state.
Future<void> _performLogout() async { Future<void> _performLogout() async {
await PushRegistration().logoutCleanup(); await SessionLifecycle.signOut();
await AuthLogout().run();
await AccountData().removeData();
_cachedDisplayName = null; _cachedDisplayName = null;
} }
}
class _AvatarEditBadge extends StatelessWidget { class _AvatarEditBadge extends StatelessWidget {
final bool busy; final bool busy;
@@ -253,11 +292,7 @@ class _AvatarEditBadge extends StatelessWidget {
color: theme.colorScheme.onPrimary, color: theme.colorScheme.onPrimary,
), ),
) )
: Icon( : Icon(Icons.edit, size: 14, color: theme.colorScheme.onPrimary),
Icons.edit,
size: 14,
color: theme.colorScheme.onPrimary,
),
), ),
), ),
); );
@@ -0,0 +1,205 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../push/push_registration.dart';
import '../../../../push/push_status.dart';
import '../../../../session/session_manager.dart';
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/centered_leading.dart';
import '../widgets/push_status_sheet.dart';
import '../widgets/settings_checkbox_tile.dart';
class NotificationsSection extends StatelessWidget {
const NotificationsSection({super.key});
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsCubit>();
return _PushSettings(
settings: settings,
capabilities: context.read<CapabilitiesCubit>(),
enabled: settings.val().notificationSettings.enabled,
devMode: settings.val().devToolsEnabled,
// The status checklist describes the Nextcloud chain (app passwords,
// keypair, general/talk registrations); a direct registration has none
// of these links.
showChainStatus: SessionManager().hasNextcloud,
);
}
}
/// The push area: the enable switch carries an at-a-glance health icon (green
/// check / red X) right before the checkbox, and the detailed status checklist
/// is hidden — it only surfaces when the chain is broken or the developer mode
/// is on. Owns the [PushStatusReport] loading so the inline icon and the detail
/// entry share one source of truth.
class _PushSettings extends StatefulWidget {
final SettingsCubit settings;
final CapabilitiesCubit capabilities;
final bool enabled;
final bool devMode;
final bool showChainStatus;
const _PushSettings({
required this.settings,
required this.capabilities,
required this.enabled,
required this.devMode,
required this.showChainStatus,
});
@override
State<_PushSettings> createState() => _PushSettingsState();
}
class _PushSettingsState extends State<_PushSettings>
with WidgetsBindingObserver {
PushStatusReport? _report;
/// True while a (de)registration triggered by the switch is in flight. The
/// report collected in that window still reflects the pre-registration state,
/// so the status is shown as "loading" instead of briefly flashing red.
bool _busy = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
unawaited(_load());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didUpdateWidget(covariant _PushSettings oldWidget) {
super.didUpdateWidget(oldWidget);
// Toggling the setting changes several links at once — re-collect.
if (oldWidget.enabled != widget.enabled) unawaited(_load());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// The OS permission can change while the app is backgrounded.
if (state == AppLifecycleState.resumed) unawaited(_load());
}
Future<void> _load() async {
if (!widget.showChainStatus) return;
final caps = widget.capabilities.state;
final report = await collectPushStatus(
settingEnabled: widget.settings.val().notificationSettings.enabled,
capabilityPush: caps.pushNotifications,
capabilitiesLoaded: caps.loaded,
);
if (!mounted) return;
setState(() => _report = report);
}
void _onToggle(bool enabled) {
widget.settings.val(write: true).notificationSettings.enabled = enabled;
// Turning off does NOT unregister: the device stays subscribed so silent
// sync pushes keep arriving; the message handler and iOS NSE suppress only
// the visible notification (via the mirrored flag). Enabling (re-)registers
// and ensures the OS permission.
if (!enabled) return;
setState(() => _busy = true);
final messenger = ScaffoldMessenger.of(context);
unawaited(() async {
try {
// Only register when the OS permission isn't explicitly denied —
// otherwise NC + proxy would push into the void.
if (await PushRegistration.requestOsPermission()) {
await PushRegistration().register();
} else {
messenger.showSnackBar(
const SnackBar(
content: Text(
'Die Benachrichtigungsberechtigung wurde in den '
'Systemeinstellungen deaktiviert. Bitte aktiviere sie dort, um '
'Push-Benachrichtigungen zu erhalten.',
),
),
);
}
} finally {
if (mounted) await _load();
if (mounted) setState(() => _busy = false);
}
}());
}
@override
Widget build(BuildContext context) {
final report = _report;
final broken =
widget.enabled && !_busy && report != null && !report.chainHealthy;
return Column(
children: [
SettingsCheckboxTile(
icon: Icons.notifications_active_outlined,
title: 'Push-Benachrichtigungen',
subtitle: widget.showChainStatus
? 'Benachrichtigungen bei neuen Talk-Nachrichten erhalten'
: 'Benachrichtigungen der Schule erhalten',
value: widget.enabled,
beforeCheckbox: _inlineStatusIcon(report),
onChanged: _onToggle,
),
// Detail entry only when there is a problem to fix or for developers.
if (widget.showChainStatus && (broken || widget.devMode))
_detailTile(error: broken),
],
);
}
/// Health icon shown before the checkbox — a spinner while a registration is
/// in flight, otherwise the green/red verdict once the report has loaded.
Widget? _inlineStatusIcon(PushStatusReport? report) {
if (_busy) {
return const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
if (!widget.enabled || report == null) return null;
final healthy = report.chainHealthy;
return Icon(
healthy ? Icons.check_circle : Icons.cancel,
color: healthy ? Colors.green : Theme.of(context).colorScheme.error,
semanticLabel: healthy ? 'Push in Ordnung' : 'Push funktioniert nicht',
);
}
/// The full status checklist entry — same list-tile footprint whether broken
/// or not; a problem is signalled only through the error-colored icon/text.
Widget _detailTile({required bool error}) {
final color = error ? Theme.of(context).colorScheme.error : null;
final textStyle = color == null ? null : TextStyle(color: color);
return ListTile(
leading: CenteredLeading(
Icon(Icons.monitor_heart_outlined, color: color),
),
title: Text('Push-Status', style: textStyle),
subtitle: Text(
error
? 'Ein Schritt in der Zustellkette ist unterbrochen'
: 'Registrierung und Zustellung im Detail',
style: textStyle,
),
trailing: Icon(Icons.arrow_right, color: color),
// The sheet can re-register; re-collect on close so the dot reflects it.
onTap: () async {
await showPushStatusSheet(context);
if (mounted) await _load();
},
);
}
}
@@ -1,15 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../push/push_registration.dart';
import '../../../../push/push_status.dart';
import '../../../../routing/app_routes.dart'; import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/centered_leading.dart';
import '../widgets/push_status_sheet.dart';
import '../widgets/settings_checkbox_tile.dart'; import '../widgets/settings_checkbox_tile.dart';
class TalkSection extends StatelessWidget { class TalkSection extends StatelessWidget {
@@ -42,180 +35,7 @@ class TalkSection extends StatelessWidget {
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () => AppRoutes.openChatBackgroundSettings(context), onTap: () => AppRoutes.openChatBackgroundSettings(context),
), ),
_PushSettings(
settings: settings,
capabilities: context.read<CapabilitiesCubit>(),
enabled: settings.val().notificationSettings.enabled,
devMode: settings.val().devToolsEnabled,
),
], ],
); );
} }
} }
/// The push area: the enable switch carries an at-a-glance health icon (green
/// check / red X) right before the checkbox, and the detailed status checklist
/// is hidden — it only surfaces when the chain is broken or the developer mode
/// is on. Owns the [PushStatusReport] loading so the inline icon and the detail
/// entry share one source of truth.
class _PushSettings extends StatefulWidget {
final SettingsCubit settings;
final CapabilitiesCubit capabilities;
final bool enabled;
final bool devMode;
const _PushSettings({
required this.settings,
required this.capabilities,
required this.enabled,
required this.devMode,
});
@override
State<_PushSettings> createState() => _PushSettingsState();
}
class _PushSettingsState extends State<_PushSettings>
with WidgetsBindingObserver {
PushStatusReport? _report;
/// True while a (de)registration triggered by the switch is in flight. The
/// report collected in that window still reflects the pre-registration state,
/// so the status is shown as "loading" instead of briefly flashing red.
bool _busy = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
unawaited(_load());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didUpdateWidget(covariant _PushSettings oldWidget) {
super.didUpdateWidget(oldWidget);
// Toggling the setting changes several links at once — re-collect.
if (oldWidget.enabled != widget.enabled) unawaited(_load());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// The OS permission can change while the app is backgrounded.
if (state == AppLifecycleState.resumed) unawaited(_load());
}
Future<void> _load() async {
final caps = widget.capabilities.state;
final report = await collectPushStatus(
settingEnabled: widget.settings.val().notificationSettings.enabled,
capabilityPush: caps.pushNotifications,
capabilitiesLoaded: caps.loaded,
);
if (!mounted) return;
setState(() => _report = report);
}
void _onToggle(bool enabled) {
widget.settings.val(write: true).notificationSettings.enabled = enabled;
// Turning off does NOT unregister: the device stays subscribed so silent
// sync pushes keep arriving; the message handler and iOS NSE suppress only
// the visible notification (via the mirrored flag). Enabling (re-)registers
// and ensures the OS permission.
if (!enabled) return;
setState(() => _busy = true);
final messenger = ScaffoldMessenger.of(context);
unawaited(() async {
try {
// Only register when the OS permission isn't explicitly denied —
// otherwise NC + proxy would push into the void.
if (await PushRegistration.requestOsPermission()) {
await PushRegistration().register();
} else {
messenger.showSnackBar(
const SnackBar(
content: Text(
'Die Benachrichtigungsberechtigung wurde in den '
'Systemeinstellungen deaktiviert. Bitte aktiviere sie dort, um '
'Push-Benachrichtigungen zu erhalten.',
),
),
);
}
} finally {
if (mounted) await _load();
if (mounted) setState(() => _busy = false);
}
}());
}
@override
Widget build(BuildContext context) {
final report = _report;
final broken =
widget.enabled && !_busy && report != null && !report.chainHealthy;
return Column(
children: [
SettingsCheckboxTile(
icon: Icons.notifications_active_outlined,
title: 'Push-Benachrichtigungen',
subtitle: 'Benachrichtigungen bei neuen Talk-Nachrichten erhalten',
value: widget.enabled,
beforeCheckbox: _inlineStatusIcon(report),
onChanged: _onToggle,
),
// Detail entry only when there is a problem to fix or for developers.
if (broken || widget.devMode) _detailTile(error: broken),
],
);
}
/// Health icon shown before the checkbox — a spinner while a registration is
/// in flight, otherwise the green/red verdict once the report has loaded.
Widget? _inlineStatusIcon(PushStatusReport? report) {
if (_busy) {
return const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
if (!widget.enabled || report == null) return null;
final healthy = report.chainHealthy;
return Icon(
healthy ? Icons.check_circle : Icons.cancel,
color: healthy ? Colors.green : Theme.of(context).colorScheme.error,
semanticLabel: healthy ? 'Push in Ordnung' : 'Push funktioniert nicht',
);
}
/// The full status checklist entry — same list-tile footprint whether broken
/// or not; a problem is signalled only through the error-colored icon/text.
Widget _detailTile({required bool error}) {
final color = error ? Theme.of(context).colorScheme.error : null;
final textStyle = color == null ? null : TextStyle(color: color);
return ListTile(
leading: CenteredLeading(
Icon(Icons.monitor_heart_outlined, color: color),
),
title: Text('Push-Status', style: textStyle),
subtitle: Text(
error
? 'Ein Schritt in der Zustellkette ist unterbrochen'
: 'Registrierung und Zustellung im Detail',
style: textStyle,
),
trailing: Icon(Icons.arrow_right, color: color),
// The sheet can re-register; re-collect on close so the dot reflects it.
onTap: () async {
await showPushStatusSheet(context);
if (mounted) await _load();
},
);
}
}
+29 -15
View File
@@ -1,35 +1,49 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../access/access_requirement.dart';
import '../../../session/session_manager.dart';
import 'sections/about_section.dart'; import 'sections/about_section.dart';
import 'sections/account_section.dart'; import 'sections/account_section.dart';
import 'sections/appearance_section.dart'; import 'sections/appearance_section.dart';
import 'sections/files_section.dart'; import 'sections/files_section.dart';
import 'sections/modules_section.dart'; import 'sections/modules_section.dart';
import 'sections/notifications_section.dart';
import 'sections/talk_section.dart'; import 'sections/talk_section.dart';
import 'sections/timetable_section.dart'; import 'sections/timetable_section.dart';
class Settings extends StatelessWidget { class Settings extends StatelessWidget {
const Settings({super.key}); const Settings({super.key});
/// Sections in display order with the backend identities they need;
/// sections the session cannot use are left out.
static const List<(Widget, Set<AccessRequirement>)> _sections = [
(AccountSection(), {}),
(AppearanceSection(), {}),
(ModulesSection(), {}),
(TimetableSection(), {}),
(NotificationsSection(), {}),
(TalkSection(), {AccessRequirement.nextcloud}),
(FilesSection(), {AccessRequirement.nextcloud}),
(AboutSection(), {}),
];
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) {
final session = SessionManager().current;
final visible = [
for (final (section, requirements) in _sections)
if (requirements.areMetBy(session)) section,
];
return Scaffold(
appBar: AppBar(title: const Text('Einstellungen')), appBar: AppBar(title: const Text('Einstellungen')),
body: ListView( body: ListView(
children: const [ children: [
AccountSection(), for (final (i, section) in visible.indexed) ...[
Divider(), if (i > 0) const Divider(),
AppearanceSection(), section,
Divider(), ],
ModulesSection(),
Divider(),
TimetableSection(),
Divider(),
TalkSection(),
Divider(),
FilesSection(),
Divider(),
AboutSection(),
], ],
), ),
); );
} }
}
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
import '../../../../model/account_data.dart';
import '../../../../model/endpoint_data.dart'; import '../../../../model/endpoint_data.dart';
import '../../../../session/session_manager.dart';
import '../../../../utils/emoji_detection.dart'; import '../../../../utils/emoji_detection.dart';
import '../../../../utils/url_opener.dart'; import '../../../../utils/url_opener.dart';
import '../widgets/highlighted_linkify.dart'; import '../widgets/highlighted_linkify.dart';
@@ -105,7 +105,7 @@ class ChatMessage {
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
errorListener: (value) {}, errorListener: (value) {},
httpHeaders: AccountData().authHeaders(), httpHeaders: SessionManager().requireNextcloud().authHeaders,
imageUrl: imageUrl:
'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1', 'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1',
), ),
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart'; import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart';
import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart'; import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart';
import '../../../../model/account_data.dart'; import '../../../../session/session_manager.dart';
import '../../../../widget/centered_leading.dart'; import '../../../../widget/centered_leading.dart';
import '../../../../widget/emoji_text.dart'; import '../../../../widget/emoji_text.dart';
import '../../../../widget/loading_spinner.dart'; import '../../../../widget/loading_spinner.dart';
@@ -63,10 +63,10 @@ class _MessageReactionsState extends State<MessageReactions> {
leading: CenteredLeading(EmojiText(entry.key)), leading: CenteredLeading(EmojiText(entry.key)),
title: Text('${entry.value.length} mal reagiert'), title: Text('${entry.value.length} mal reagiert'),
children: entry.value.map((e) { children: entry.value.map((e) {
final isSelf = AccountData().getUsername() == e.actorId; final isSelf =
SessionManager().requireNextcloud().username == e.actorId;
final isGuest = final isGuest =
e.actorType == e.actorType == GetReactionsResponseObjectActorType.guests;
GetReactionsResponseObjectActorType.guests;
return ListTile( return ListTile(
leading: UserAvatar(id: e.actorId, isGroup: false), leading: UserAvatar(id: e.actorId, isGroup: false),
title: Text(e.actorDisplayName), title: Text(e.actorDisplayName),
@@ -2,7 +2,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart';
import '../../../../model/account_data.dart'; import '../../../../session/session_manager.dart';
import '../../../../widget/user_avatar.dart'; import '../../../../widget/user_avatar.dart';
import '../data/open_direct_chat.dart'; import '../data/open_direct_chat.dart';
@@ -36,7 +36,7 @@ class ParticipantsListView extends StatelessWidget {
(participant) => participant.participantType, (participant) => participant.participantType,
); );
final selfId = AccountData().getUsername(); final selfId = SessionManager().requireNextcloud().username;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Mitglieder')), appBar: AppBar(title: const Text('Mitglieder')),
body: ListView( body: ListView(
@@ -9,8 +9,8 @@ import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_ove
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart'; import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../extensions/date_time.dart'; import '../../../../extensions/date_time.dart';
import '../../../../model/account_data.dart';
import '../../../../model/endpoint_data.dart'; import '../../../../model/endpoint_data.dart';
import '../../../../session/session_manager.dart';
import '../../../../share_intent/remote_file_ref.dart'; import '../../../../share_intent/remote_file_ref.dart';
import '../../../../utils/downloads/download_job.dart'; import '../../../../utils/downloads/download_job.dart';
import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/app_progress_indicator.dart';
@@ -54,7 +54,8 @@ class SharedItemsPage {
const SharedItemsPage(this.items, this.lastKnownMessageId, this.hasMore); const SharedItemsPage(this.items, this.lastKnownMessageId, this.hasMore);
} }
List<GetChatResponseObject> _fileItems(List<GetChatResponseObject> items) => items List<GetChatResponseObject> _fileItems(List<GetChatResponseObject> items) =>
items
.where((item) => item.messageParameters?['file']?.path != null) .where((item) => item.messageParameters?['file']?.path != null)
.toList(); .toList();
@@ -140,7 +141,9 @@ class _SharedItemsViewState extends State<SharedItemsView>
Future<void> _load() async { Future<void> _load() async {
setState(() => _error = null); setState(() => _error = null);
try { try {
final overview = await SharedItemsView.prefetchOverview(widget.room.token); final overview = await SharedItemsView.prefetchOverview(
widget.room.token,
);
if (!mounted) return; if (!mounted) return;
_overview = overview; _overview = overview;
_prepareTabs(); _prepareTabs();
@@ -501,7 +504,10 @@ class _SharedItemTileState extends State<_SharedItemTile>
if (isDownloading) { if (isDownloading) {
confirmCancelDownload(); confirmCancelDownload();
} else { } else {
startDownload(name: _file.name, remoteFile: RemoteFileRef.fromTalk(_file)); startDownload(
name: _file.name,
remoteFile: RemoteFileRef.fromTalk(_file),
);
} }
} }
@@ -533,7 +539,7 @@ class _SharedItemTileState extends State<_SharedItemTile>
children: [ children: [
CachedNetworkImage( CachedNetworkImage(
imageUrl: _previewUrl, imageUrl: _previewUrl,
httpHeaders: AccountData().authHeaders(), httpHeaders: SessionManager().requireNextcloud().authHeaders,
fit: BoxFit.cover, fit: BoxFit.cover,
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
+3 -7
View File
@@ -8,9 +8,9 @@ import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dar
import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../extensions/date_time.dart'; import '../../../../extensions/date_time.dart';
import '../../../../model/account_data.dart';
import '../../../../notification/notification_tasks.dart'; import '../../../../notification/notification_tasks.dart';
import '../../../../routing/app_routes.dart'; import '../../../../routing/app_routes.dart';
import '../../../../session/session_manager.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.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/chat_list/bloc/chat_list_bloc.dart';
import '../../../../utils/haptics.dart'; import '../../../../utils/haptics.dart';
@@ -51,13 +51,9 @@ class _ChatTileState extends State<ChatTile> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
AccountData().waitForPopulation().then((_) { SessionManager().waitForLoad().then((session) {
if (!mounted) return; if (!mounted) return;
setState( setState(() => selfUsername = session?.nextcloud?.username);
() => selfUsername = AccountData().isPopulated()
? AccountData().getUsername()
: null,
);
}); });
} }

Some files were not shown because too many files have changed in this diff Show More