added guardian login with views for their assigned childs
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}) {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-5
@@ -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.
|
||||||
|
|||||||
+18
@@ -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
@@ -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, _) {
|
||||||
|
|||||||
@@ -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('=', '');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/// A sign-in link from the guardian login mail
|
||||||
|
/// (`https://<connect-host>/app/guardian-login?rid=…<=…`).
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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()};
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'))}';
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-64
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user