added guardian login with views for their assigned childs
This commit is contained in:
@@ -2,7 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../model/account_data.dart';
|
||||
import '../../session/session_manager.dart';
|
||||
import '../../theming/light_app_theme.dart';
|
||||
import '../../widget/app_progress_indicator.dart';
|
||||
|
||||
@@ -61,7 +61,7 @@ class _AccountLoadingScreenState extends State<AccountLoadingScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: AccountData().abandonLoad,
|
||||
onPressed: SessionManager().abandonLoad,
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.white),
|
||||
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_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 '../../state/app/modules/account/bloc/account_bloc.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 '../../utils/haptics.dart';
|
||||
import '../pages/settings/widgets/endpoint_picker.dart';
|
||||
import 'guardian_login_controller.dart';
|
||||
import 'login_controller.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_card.dart';
|
||||
|
||||
@@ -28,12 +34,45 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
||||
static const _marianumRed = LightAppTheme.marianumRed;
|
||||
|
||||
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(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 450),
|
||||
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
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -43,8 +82,10 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
GuardianLinkListener.pending.removeListener(_consumeGuardianLink);
|
||||
_fade.dispose();
|
||||
_controller.dispose();
|
||||
_guardianController.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
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
backgroundColor: _marianumRed,
|
||||
@@ -89,12 +144,33 @@ class _LoginState extends State<Login> with SingleTickerProviderStateMixin {
|
||||
children: [
|
||||
const LoginHeader(),
|
||||
const SizedBox(height: 28),
|
||||
LoginCard(
|
||||
controller: _controller,
|
||||
onSuccess: _onLoginSuccess,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const LoginDisclaimer(),
|
||||
_buildCard(),
|
||||
if (_audience != null)
|
||||
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,
|
||||
),
|
||||
icon: const Icon(Icons.arrow_back, size: 18),
|
||||
label: const Text('Zurück zur Auswahl'),
|
||||
onPressed:
|
||||
_controller.loading ||
|
||||
_guardianController.loading
|
||||
? null
|
||||
: () => setState(() => _audience = null),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
const Column(
|
||||
|
||||
@@ -10,7 +10,8 @@ import '../../api/marianumconnect/auth/device_token_name.dart';
|
||||
import '../../api/marianumconnect/auth/token_storage.dart';
|
||||
import '../../api/marianumconnect/queries/auth_login/auth_login.dart';
|
||||
import '../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||
import '../../model/account_data.dart';
|
||||
import '../../session/session.dart';
|
||||
import '../../session/session_manager.dart';
|
||||
import '../../widget_data/widget_sync.dart';
|
||||
|
||||
/// Outcome of a login attempt.
|
||||
@@ -51,25 +52,17 @@ class LoginController extends ChangeNotifier {
|
||||
// Demo login: the prefix enters local demo mode, password ignored, no
|
||||
// network (see DemoMode).
|
||||
if (DemoMode.matches(user)) {
|
||||
await AccountData().removeData();
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
await WidgetSync.clear();
|
||||
await WidgetSync.triggerUpdate();
|
||||
await AccountData().setDemo(user);
|
||||
await _discardPreviousAccount();
|
||||
await SessionManager().signIn(
|
||||
CredentialSession(username: user, password: 'demo', isDemo: true),
|
||||
);
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return LoginResult.success;
|
||||
}
|
||||
|
||||
try {
|
||||
await AccountData().removeData();
|
||||
// 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();
|
||||
await _discardPreviousAccount();
|
||||
// AuthLogin = Credential-Probe + Token-Create in einem Call.
|
||||
// 401 hier heißt: falsches Passwort.
|
||||
await AuthLogin().run(
|
||||
@@ -77,7 +70,9 @@ class LoginController extends ChangeNotifier {
|
||||
password: password,
|
||||
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
|
||||
// credential probe: a rejection means 2FA is active (or the NC password
|
||||
// diverges) and the login must finish interactively in the browser.
|
||||
@@ -87,7 +82,7 @@ class LoginController extends ChangeNotifier {
|
||||
return ncReady ? LoginResult.success : LoginResult.nextcloudLoginRequired;
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
await AccountData().removeData();
|
||||
await SessionManager().signOut();
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
final isWrongCredentials = e is AuthException && e.statusCode == 401;
|
||||
_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.
|
||||
/// `false` = Nextcloud rejected the credentials → Login Flow v2 required.
|
||||
/// Transport/server problems stay non-blocking (like the previous
|
||||
@@ -107,7 +112,7 @@ class LoginController extends ChangeNotifier {
|
||||
Future<bool> _prepareNextcloudAppPassword() async {
|
||||
try {
|
||||
final appPassword = await GetAppPassword().run();
|
||||
await AccountData().setAppPassword(appPassword);
|
||||
await SessionManager().setAppPassword(appPassword);
|
||||
return true;
|
||||
} on AuthException {
|
||||
return false;
|
||||
@@ -126,7 +131,7 @@ class LoginController extends ChangeNotifier {
|
||||
} on Object catch (e) {
|
||||
log('Login rollback: MC logout failed: $e');
|
||||
}
|
||||
await AccountData().removeData();
|
||||
await SessionManager().signOut();
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
_errorMessage =
|
||||
'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/marianumcloud/app_password/delete_app_password.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 '../../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
|
||||
/// 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
|
||||
/// subscriptions (see PushRegistrationType). Pops `true` once the primary
|
||||
/// 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);
|
||||
if (credentials == null || _finished || !mounted) return;
|
||||
if (!LoginFlowApi.loginNameMatches(
|
||||
expected: AccountData().getUsername(),
|
||||
expected: SessionManager().requireNextcloud().username,
|
||||
actual: credentials.loginName,
|
||||
)) {
|
||||
_timer?.cancel();
|
||||
@@ -120,7 +120,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
||||
}
|
||||
switch (_step) {
|
||||
case _FlowStep.primary:
|
||||
await AccountData().setLoginFlow(credentials.appPassword);
|
||||
await SessionManager().setLoginFlow(credentials.appPassword);
|
||||
if (!mounted) return;
|
||||
// Zweite Freigabe direkt anstoßen: die Browser-Session besteht
|
||||
// bereits, es fehlt nur noch der Grant-Tipp.
|
||||
@@ -129,7 +129,7 @@ class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
||||
case _FlowStep.talk:
|
||||
_finished = true;
|
||||
_timer?.cancel();
|
||||
await AccountData().setAppPasswordTalk(credentials.appPassword);
|
||||
await SessionManager().setAppPasswordTalk(credentials.appPassword);
|
||||
if (!mounted) return;
|
||||
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),
|
||||
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,
|
||||
style: TextStyle(
|
||||
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 {
|
||||
const LoginFooter({super.key});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../login_controller.dart';
|
||||
import 'login_error_banner.dart';
|
||||
import 'login_form_parts.dart';
|
||||
|
||||
/// White Card hosting the login form (heading, two text fields, error
|
||||
/// banner, submit button). Submitting calls [controller.submit] and signals
|
||||
@@ -75,122 +76,62 @@ 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
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final loading = widget.controller.loading;
|
||||
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: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
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(
|
||||
key: const Key('login-username-field'),
|
||||
controller: _usernameController,
|
||||
enabled: !loading,
|
||||
validator: _required,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||
decoration: _decoration(
|
||||
theme,
|
||||
'Nutzername',
|
||||
Icons.person_outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
key: const Key('login-password-field'),
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
enabled: !loading,
|
||||
validator: _required,
|
||||
obscureText: true,
|
||||
obscuringCharacter: '•',
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
decoration: _decoration(theme, 'Passwort', Icons.lock_outline),
|
||||
),
|
||||
LoginErrorBanner(
|
||||
message: widget.controller.errorMessage,
|
||||
details: widget.controller.errorDetails,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
key: const Key('login-submit-button'),
|
||||
onPressed: loading ? null : _submit,
|
||||
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,
|
||||
),
|
||||
)
|
||||
: const Text('Anmelden'),
|
||||
),
|
||||
),
|
||||
],
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: LoginCardFrame(
|
||||
title: 'Anmelden',
|
||||
hint: 'Melde dich mit deinen Marianum-Zugangsdaten an.',
|
||||
children: [
|
||||
TextFormField(
|
||||
key: const Key('login-username-field'),
|
||||
controller: _usernameController,
|
||||
enabled: !loading,
|
||||
validator: _required,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.next,
|
||||
onFieldSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||
decoration: loginInputDecoration(
|
||||
theme,
|
||||
'Nutzername',
|
||||
Icons.person_outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
key: const Key('login-password-field'),
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
enabled: !loading,
|
||||
validator: _required,
|
||||
obscureText: true,
|
||||
obscuringCharacter: '•',
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
decoration: loginInputDecoration(
|
||||
theme,
|
||||
'Passwort',
|
||||
Icons.lock_outline,
|
||||
),
|
||||
),
|
||||
LoginErrorBanner(
|
||||
message: widget.controller.errorMessage,
|
||||
details: widget.controller.errorDetails,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
LoginSubmitButton(
|
||||
key: const Key('login-submit-button'),
|
||||
label: 'Anmelden',
|
||||
loading: loading,
|
||||
onPressed: _submit,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../api/errors/error_mapper.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_submit.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/async_action_button.dart';
|
||||
import '../../../widget/child_switcher.dart';
|
||||
import '../../../widget/demo_restricted.dart';
|
||||
import '../../../widget/focus_behaviour.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import 'absence_form_policy.dart';
|
||||
|
||||
/// 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
|
||||
/// 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`).
|
||||
class AbsenceReportView extends StatefulWidget {
|
||||
class AbsenceReportView extends StatelessWidget {
|
||||
const AbsenceReportView({super.key});
|
||||
|
||||
@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.';
|
||||
|
||||
final TextEditingController _firstName = TextEditingController();
|
||||
@@ -67,7 +99,17 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
if (_submitted) setState(() {});
|
||||
}
|
||||
|
||||
String? get _childId => widget.policy.child?.id;
|
||||
|
||||
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
|
||||
// (mapped to null on failure), so a classes error still propagates while a
|
||||
// prefill failure never surfaces as an unhandled async error.
|
||||
@@ -148,6 +190,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
absentUntil: _absentUntil,
|
||||
phone: _phone.text.trim(),
|
||||
note: _note.text.trim(),
|
||||
childId: _childId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
// Replace the whole form with a terminal success screen. There is
|
||||
@@ -157,10 +200,8 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Abwesenheitsmeldung')),
|
||||
body: _done ? const _SubmittedView() : _buildBody(context),
|
||||
);
|
||||
Widget build(BuildContext context) =>
|
||||
_done ? const _SubmittedView() : _buildBody(context);
|
||||
|
||||
Widget _buildBody(BuildContext context) => FutureBuilder<void>(
|
||||
future: _init,
|
||||
@@ -205,6 +246,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: _firstName,
|
||||
readOnly: !widget.policy.identityEditable,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: _decoration(
|
||||
'Vorname',
|
||||
@@ -215,6 +257,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _lastName,
|
||||
readOnly: !widget.policy.identityEditable,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: _decoration(
|
||||
'Nachname',
|
||||
@@ -234,7 +277,9 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
|
||||
items: _classes
|
||||
.map((c) => DropdownMenuItem(value: c, child: Text(c)))
|
||||
.toList(),
|
||||
onChanged: (value) => setState(() => _selectedClass = value),
|
||||
onChanged: widget.policy.identityEditable
|
||||
? (value) => setState(() => _selectedClass = value)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DateField(
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../data/file_type_icon.dart';
|
||||
|
||||
/// Leading slot for a file row: shows the Nextcloud thumbnail when the
|
||||
@@ -35,7 +35,7 @@ class FileLeading extends StatelessWidget {
|
||||
'https://${EndpointData().nextcloud().full()}'
|
||||
'/index.php/core/preview'
|
||||
'?fileId=$fileId&x=128&y=128&a=0',
|
||||
httpHeaders: AccountData().authHeaders(),
|
||||
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../timetable/custom_events/custom_event_edit_dialog.dart';
|
||||
import '../data/event_formatter.dart';
|
||||
import 'event_details_sheet.dart';
|
||||
@@ -89,24 +91,31 @@ class MarianumDateRow extends StatelessWidget {
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: _CalendarPlusIcon(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
tooltip: 'In Stundenplan übernehmen',
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => CustomEventEditDialog(
|
||||
initialTitle: event.title,
|
||||
initialDescription: event.description,
|
||||
initialStart: event.start,
|
||||
initialEnd: event.end,
|
||||
initialAllDay: event.isAllDay,
|
||||
// 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),
|
||||
IconButton(
|
||||
icon: _CalendarPlusIcon(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
tooltip: 'In Stundenplan übernehmen',
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => CustomEventEditDialog(
|
||||
initialTitle: event.title,
|
||||
initialDescription: event.description,
|
||||
initialStart: event.start,
|
||||
initialEnd: event.end,
|
||||
initialAllDay: event.isAllDay,
|
||||
),
|
||||
barrierDismissible: false,
|
||||
),
|
||||
barrierDismissible: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,15 +4,19 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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 '../../../../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_state.dart';
|
||||
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/avatar_actions_sheet.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/child_switcher.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/demo_restricted.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.
|
||||
String? _cachedDisplayName;
|
||||
|
||||
class AccountSection extends StatefulWidget {
|
||||
class AccountSection extends StatelessWidget {
|
||||
const AccountSection({super.key});
|
||||
|
||||
@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;
|
||||
bool _avatarBusy = false;
|
||||
String? _displayName = _cachedDisplayName;
|
||||
@@ -42,9 +85,7 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
Future<void> _loadDisplayName() async {
|
||||
try {
|
||||
final info = await GetUserInfo().run();
|
||||
_cachedDisplayName = info.displayName.isEmpty
|
||||
? null
|
||||
: info.displayName;
|
||||
_cachedDisplayName = info.displayName.isEmpty ? null : info.displayName;
|
||||
if (!mounted) return;
|
||||
setState(() => _displayName = _cachedDisplayName);
|
||||
} catch (_) {
|
||||
@@ -84,13 +125,17 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
setState(() => _avatarBusy = false);
|
||||
if (!ok) return;
|
||||
|
||||
invalidateAvatarCache(id: AccountData().getUsername(), isGroup: false);
|
||||
invalidateAvatarCache(
|
||||
id: SessionManager().requireNextcloud().username,
|
||||
isGroup: false,
|
||||
);
|
||||
setState(() => _avatarVersion++);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final username = AccountData().getUsername();
|
||||
final nextcloud = SessionManager().requireNextcloud();
|
||||
final username = nextcloud.username;
|
||||
final displayName = _displayName;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
@@ -109,8 +154,10 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
children: [
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onTap: () =>
|
||||
AppRoutes.openLargeProfilePicture(context, username),
|
||||
onTap: () => AppRoutes.openLargeProfilePicture(
|
||||
context,
|
||||
username,
|
||||
),
|
||||
child: UserAvatar(
|
||||
key: ValueKey(_avatarVersion),
|
||||
id: username,
|
||||
@@ -164,7 +211,7 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.logout_outlined, size: 18),
|
||||
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
|
||||
// sich still über das App-Passwort-Minting und sollen von dem ganzen
|
||||
// Flow-Mechanismus nichts mitbekommen.
|
||||
if (!AccountData().isDemo && AccountData().usesLoginFlow)
|
||||
if (!SessionManager().isDemo && nextcloud.usesLoginFlow)
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.cloud_sync_outlined),
|
||||
title: const Text('Nextcloud neu verbinden'),
|
||||
subtitle: const Text(
|
||||
'Bei Anmeldeproblemen in Talk oder Dateien',
|
||||
),
|
||||
subtitle: const Text('Bei Anmeldeproblemen in Talk oder Dateien'),
|
||||
closeOnSuccess: false,
|
||||
onPressed: _reconnectNextcloud,
|
||||
),
|
||||
@@ -197,35 +242,29 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
const SnackBar(content: Text('Nextcloud-Verbindung erneuert.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showLogoutDialog(BuildContext context) async {
|
||||
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
||||
// inside removeData (the previous approach) raced AsyncDialogAction's
|
||||
// pop(true) against the listener's popUntil(isFirst) and could leave the
|
||||
// navigator in an inconsistent state.
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => ConfirmDialog(
|
||||
title: 'Abmelden?',
|
||||
content: 'Möchtest du dich wirklich abmelden?',
|
||||
confirmButton: 'Abmelden',
|
||||
onConfirmAsync: _performLogout,
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
|
||||
}
|
||||
Future<void> _confirmLogout(BuildContext context) async {
|
||||
// Flip AccountBloc state only after the dialog fully closes: doing it from
|
||||
// inside the sign-out (the previous approach) raced AsyncDialogAction's
|
||||
// pop(true) against the listener's popUntil(isFirst) and could leave the
|
||||
// navigator in an inconsistent state.
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => ConfirmDialog(
|
||||
title: 'Abmelden?',
|
||||
content: 'Möchtest du dich wirklich abmelden?',
|
||||
confirmButton: 'Abmelden',
|
||||
onConfirmAsync: _performLogout,
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
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 {
|
||||
await PushRegistration().logoutCleanup();
|
||||
await AuthLogout().run();
|
||||
await AccountData().removeData();
|
||||
_cachedDisplayName = null;
|
||||
}
|
||||
Future<void> _performLogout() async {
|
||||
await SessionLifecycle.signOut();
|
||||
_cachedDisplayName = null;
|
||||
}
|
||||
|
||||
class _AvatarEditBadge extends StatelessWidget {
|
||||
@@ -253,11 +292,7 @@ class _AvatarEditBadge extends StatelessWidget {
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.edit,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
: Icon(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_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../push/push_registration.dart';
|
||||
import '../../../../push/push_status.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 '../../../../widget/centered_leading.dart';
|
||||
import '../widgets/push_status_sheet.dart';
|
||||
import '../widgets/settings_checkbox_tile.dart';
|
||||
|
||||
class TalkSection extends StatelessWidget {
|
||||
@@ -42,180 +35,7 @@ class TalkSection extends StatelessWidget {
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
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 '../../../access/access_requirement.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import 'sections/about_section.dart';
|
||||
import 'sections/account_section.dart';
|
||||
import 'sections/appearance_section.dart';
|
||||
import 'sections/files_section.dart';
|
||||
import 'sections/modules_section.dart';
|
||||
import 'sections/notifications_section.dart';
|
||||
import 'sections/talk_section.dart';
|
||||
import 'sections/timetable_section.dart';
|
||||
|
||||
class Settings extends StatelessWidget {
|
||||
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
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Einstellungen')),
|
||||
body: ListView(
|
||||
children: const [
|
||||
AccountSection(),
|
||||
Divider(),
|
||||
AppearanceSection(),
|
||||
Divider(),
|
||||
ModulesSection(),
|
||||
Divider(),
|
||||
TimetableSection(),
|
||||
Divider(),
|
||||
TalkSection(),
|
||||
Divider(),
|
||||
FilesSection(),
|
||||
Divider(),
|
||||
AboutSection(),
|
||||
],
|
||||
),
|
||||
);
|
||||
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')),
|
||||
body: ListView(
|
||||
children: [
|
||||
for (final (i, section) in visible.indexed) ...[
|
||||
if (i > 0) const Divider(),
|
||||
section,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../utils/emoji_detection.dart';
|
||||
import '../../../../utils/url_opener.dart';
|
||||
import '../widgets/highlighted_linkify.dart';
|
||||
@@ -105,7 +105,7 @@ class ChatMessage {
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorListener: (value) {},
|
||||
httpHeaders: AccountData().authHeaders(),
|
||||
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||
imageUrl:
|
||||
'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_response.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/emoji_text.dart';
|
||||
import '../../../../widget/loading_spinner.dart';
|
||||
@@ -63,10 +63,10 @@ class _MessageReactionsState extends State<MessageReactions> {
|
||||
leading: CenteredLeading(EmojiText(entry.key)),
|
||||
title: Text('${entry.value.length} mal reagiert'),
|
||||
children: entry.value.map((e) {
|
||||
final isSelf = AccountData().getUsername() == e.actorId;
|
||||
final isSelf =
|
||||
SessionManager().requireNextcloud().username == e.actorId;
|
||||
final isGuest =
|
||||
e.actorType ==
|
||||
GetReactionsResponseObjectActorType.guests;
|
||||
e.actorType == GetReactionsResponseObjectActorType.guests;
|
||||
return ListTile(
|
||||
leading: UserAvatar(id: e.actorId, isGroup: false),
|
||||
title: Text(e.actorDisplayName),
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.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 '../data/open_direct_chat.dart';
|
||||
|
||||
@@ -36,7 +36,7 @@ class ParticipantsListView extends StatelessWidget {
|
||||
(participant) => participant.participantType,
|
||||
);
|
||||
|
||||
final selfId = AccountData().getUsername();
|
||||
final selfId = SessionManager().requireNextcloud().username;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Mitglieder')),
|
||||
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/room/get_room_response.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../share_intent/remote_file_ref.dart';
|
||||
import '../../../../utils/downloads/download_job.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
@@ -54,9 +54,10 @@ class SharedItemsPage {
|
||||
const SharedItemsPage(this.items, this.lastKnownMessageId, this.hasMore);
|
||||
}
|
||||
|
||||
List<GetChatResponseObject> _fileItems(List<GetChatResponseObject> items) => items
|
||||
.where((item) => item.messageParameters?['file']?.path != null)
|
||||
.toList();
|
||||
List<GetChatResponseObject> _fileItems(List<GetChatResponseObject> items) =>
|
||||
items
|
||||
.where((item) => item.messageParameters?['file']?.path != null)
|
||||
.toList();
|
||||
|
||||
SharedItemsPage buildSharedItemsPage(
|
||||
GetSharedItemsResponse response,
|
||||
@@ -140,7 +141,9 @@ class _SharedItemsViewState extends State<SharedItemsView>
|
||||
Future<void> _load() async {
|
||||
setState(() => _error = null);
|
||||
try {
|
||||
final overview = await SharedItemsView.prefetchOverview(widget.room.token);
|
||||
final overview = await SharedItemsView.prefetchOverview(
|
||||
widget.room.token,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_overview = overview;
|
||||
_prepareTabs();
|
||||
@@ -501,7 +504,10 @@ class _SharedItemTileState extends State<_SharedItemTile>
|
||||
if (isDownloading) {
|
||||
confirmCancelDownload();
|
||||
} 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: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: _previewUrl,
|
||||
httpHeaders: AccountData().authHeaders(),
|
||||
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: 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/set_read_marker/set_read_marker.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../notification/notification_tasks.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_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../../utils/haptics.dart';
|
||||
@@ -51,13 +51,9 @@ class _ChatTileState extends State<ChatTile> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
AccountData().waitForPopulation().then((_) {
|
||||
SessionManager().waitForLoad().then((session) {
|
||||
if (!mounted) return;
|
||||
setState(
|
||||
() => selfUsername = AccountData().isPopulated()
|
||||
? AccountData().getUsername()
|
||||
: null,
|
||||
);
|
||||
setState(() => selfUsername = session?.nextcloud?.username);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../../../../api/marianumcloud/talk/get_poll/get_poll_state_response.dart
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/vote_poll/vote_poll.dart';
|
||||
import '../../../../api/marianumcloud/talk/vote_poll/vote_poll_params.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/demo_restricted.dart';
|
||||
@@ -186,7 +186,7 @@ class _PollOptionsListState extends State<PollOptionsList> {
|
||||
|
||||
Widget _actionBar(GetPollStateResponseObject poll, ThemeData theme) {
|
||||
final canClose = poll.canClose(
|
||||
selfId: AccountData().getUsername(),
|
||||
selfId: SessionManager().requireNextcloud().username,
|
||||
participantType: widget.room.participantType,
|
||||
);
|
||||
if (!_isInteractive && !canClose) return const SizedBox.shrink();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../access/user_role.dart';
|
||||
import '../../../../api/marianumconnect/queries/user_search/user_search_response.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
|
||||
@@ -18,14 +19,14 @@ class UserSearchTile extends StatelessWidget {
|
||||
leading: UserAvatar(id: user.username, isGroup: false),
|
||||
title: Text('${user.firstName} ${user.lastName}'),
|
||||
subtitle: Text(_subtitle),
|
||||
trailing: RoleBadge(userType: user.userType),
|
||||
trailing: RoleBadge(role: UserRole.parse(user.userType)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
String get _subtitle {
|
||||
final className = user.className;
|
||||
if (user.userType == 'STUDENT' &&
|
||||
if (UserRole.parse(user.userType) == UserRole.student &&
|
||||
className != null &&
|
||||
className.isNotEmpty) {
|
||||
return '${user.username} · $className';
|
||||
@@ -36,16 +37,17 @@ class UserSearchTile extends StatelessWidget {
|
||||
|
||||
/// Compact colour-coded badge distinguishing teachers, students and staff.
|
||||
class RoleBadge extends StatelessWidget {
|
||||
final String userType;
|
||||
final UserRole role;
|
||||
|
||||
const RoleBadge({super.key, required this.userType});
|
||||
const RoleBadge({super.key, required this.role});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color) = switch (userType) {
|
||||
'TEACHER' => ('Lehrkraft', Colors.blue),
|
||||
'STUDENT' => ('Schüler:in', Colors.green),
|
||||
_ => ('Personal', Colors.orange),
|
||||
final (label, color) = switch (role) {
|
||||
UserRole.teacher => ('Lehrkraft', Colors.blue),
|
||||
UserRole.student => ('Schüler:in', Colors.green),
|
||||
UserRole.parent => ('Elternteil', Colors.purple),
|
||||
UserRole.staff || UserRole.unknown => ('Personal', Colors.orange),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
|
||||
@@ -6,11 +6,14 @@ import '../../../extensions/date_time.dart';
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.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_state.dart';
|
||||
import '../../../state/app/modules/timetable/policy/timetable_policy.dart';
|
||||
import '../../../state/app/modules/timetable/subject/timetable_subject.dart';
|
||||
import '../../../utils/haptics.dart';
|
||||
import '../../../widget/app_progress_indicator.dart';
|
||||
import '../../../widget/child_switcher.dart';
|
||||
import '../../../widget/demo_restricted.dart';
|
||||
import 'custom_events/custom_event_edit_dialog.dart';
|
||||
import 'details/appointment_details_dispatcher.dart';
|
||||
@@ -26,8 +29,9 @@ class Timetable extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TimetableState extends State<Timetable> {
|
||||
final GlobalKey<TimetableCalendarViewState> _calendarKey =
|
||||
GlobalKey<TimetableCalendarViewState> _calendarKey =
|
||||
GlobalKey<TimetableCalendarViewState>();
|
||||
TimetableSubject? _calendarSubject;
|
||||
|
||||
/// When non-null the view shows this element's plan inline instead of the
|
||||
/// user's own. Cleared (back to own plan) via the viewing banner.
|
||||
@@ -78,31 +82,39 @@ class _TimetableState extends State<Timetable> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = _selected;
|
||||
if (selected == null) return _buildOwnPlan(context);
|
||||
if (selected == null) {
|
||||
final primary = context.watch<TimetableBloc>().subject;
|
||||
if (primary is NoTimetable) return const _NoTimetableView();
|
||||
return _buildPlan<TimetableBloc>(context);
|
||||
}
|
||||
// Scope the foreign bloc to the current selection so switching elements
|
||||
// (or back to the own plan) tears it down and builds a fresh one.
|
||||
return BlocProvider<ForeignTimetableBloc>(
|
||||
return BlocProvider<ScopedTimetableBloc>(
|
||||
key: ValueKey('${selected.type.name}-${selected.id}'),
|
||||
create: (_) => ForeignTimetableBloc(
|
||||
type: selected.type,
|
||||
elementId: selected.id,
|
||||
title: selected.label,
|
||||
),
|
||||
// Builder gives us a context *below* the provider so the foreign bloc is
|
||||
// resolvable inside _buildForeignPlan.
|
||||
create: (_) => ScopedTimetableBloc(subject: ElementTimetable(selected)),
|
||||
// Builder gives us a context *below* the provider so the scoped bloc is
|
||||
// resolvable inside _buildPlan.
|
||||
child: Builder(
|
||||
builder: (context) => _buildForeignPlan(context, selected),
|
||||
builder: (context) => _buildPlan<ScopedTimetableBloc>(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOwnPlan(BuildContext context) {
|
||||
final bloc = context.read<TimetableBloc>();
|
||||
final loadableState = context.watch<TimetableBloc>().state;
|
||||
final innerState = loadableState.data;
|
||||
Widget _buildPlan<B extends TimetableBloc>(BuildContext context) {
|
||||
final bloc = context.read<B>();
|
||||
final subject = bloc.subject;
|
||||
// A new subject (child switch) must not inherit the displayed week of the
|
||||
// previous calendar state.
|
||||
if (subject != _calendarSubject) {
|
||||
_calendarSubject = subject;
|
||||
_calendarKey = GlobalKey<TimetableCalendarViewState>();
|
||||
}
|
||||
final innerState = context.watch<B>().state.data;
|
||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||
final capabilities = context.watch<CapabilitiesCubit>();
|
||||
final canViewForeign = capabilities.canViewForeignTimetables;
|
||||
final policy = TimetablePolicy.resolve(
|
||||
subject: subject,
|
||||
capabilities: context.watch<CapabilitiesCubit>().state,
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// Der Kalender scrollt nicht (nur ziehen/reloaden), aber seine internen
|
||||
@@ -111,87 +123,36 @@ class _TimetableState extends State<Timetable> {
|
||||
notificationPredicate: (_) => false,
|
||||
title: const Text('Stunden & Vertretungsplan'),
|
||||
actions: [
|
||||
// Hides itself unless a guardian has more than one child.
|
||||
const ChildSwitcher(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
tooltip: 'Zur aktuellen Woche',
|
||||
onPressed: atToday ? null : _jumpToToday,
|
||||
),
|
||||
PopupMenuButton<_CalendarAction>(
|
||||
tooltip: 'Kalendereinträge',
|
||||
icon: const Icon(Icons.edit_calendar_outlined),
|
||||
onSelected: _onAction,
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.addEvent,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereintrag hinzufügen'),
|
||||
leading: Icon(Icons.add),
|
||||
if (policy.canManageCustomEvents)
|
||||
PopupMenuButton<_CalendarAction>(
|
||||
tooltip: 'Kalendereinträge',
|
||||
icon: const Icon(Icons.edit_calendar_outlined),
|
||||
onSelected: _onAction,
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.addEvent,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereintrag hinzufügen'),
|
||||
leading: Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.viewEvents,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereinträge anzeigen'),
|
||||
leading: Icon(Icons.perm_contact_calendar_outlined),
|
||||
PopupMenuItem(
|
||||
value: _CalendarAction.viewEvents,
|
||||
child: ListTile(
|
||||
title: Text('Kalendereinträge anzeigen'),
|
||||
leading: Icon(Icons.perm_contact_calendar_outlined),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (canViewForeign)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_search),
|
||||
tooltip: 'Anderen Stundenplan öffnen',
|
||||
onPressed: _openPicker,
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
|
||||
// Without this predicate the consumer treats the freshly-initialised
|
||||
// empty TimetableState as "has content" and only shows the error bar
|
||||
// on top — but the calendar view collapses to `SizedBox.shrink()`
|
||||
// while the reference data is missing, leaving the user with a blank
|
||||
// screen. Telling the consumer that "ready" means having reference
|
||||
// data flips it into the proper error-screen path instead.
|
||||
isReady: (state) => state.hasReferenceData,
|
||||
child: (state, _) => TimetableCalendarView(
|
||||
key: _calendarKey,
|
||||
state: state,
|
||||
onWeekChanged: bloc.changeWeek,
|
||||
onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(
|
||||
context,
|
||||
state,
|
||||
apt,
|
||||
canEditSubjectColor: true,
|
||||
),
|
||||
onCreateEvent: _onCreateEventAt,
|
||||
customEvents: state.customEvents?.events ?? const [],
|
||||
showClassInsteadOfTeacher: capabilities.isTeacher,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForeignPlan(BuildContext context, TimetableElementRef selected) {
|
||||
final bloc = context.read<ForeignTimetableBloc>();
|
||||
final loadableState = context.watch<ForeignTimetableBloc>().state;
|
||||
final innerState = loadableState.data;
|
||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||
final canViewForeign = context
|
||||
.watch<CapabilitiesCubit>()
|
||||
.canViewForeignTimetables;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// Siehe _buildOwnPlan: den scroll-under-Farbwechsel unterdrücken, weil
|
||||
// der Kalender nicht scrollt, aber ScrollNotifications feuert.
|
||||
notificationPredicate: (_) => false,
|
||||
title: const Text('Stunden & Vertretungsplan'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
tooltip: 'Zur aktuellen Woche',
|
||||
onPressed: atToday ? null : _jumpToToday,
|
||||
),
|
||||
if (canViewForeign)
|
||||
if (policy.canOpenForeign)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_search),
|
||||
tooltip: 'Anderen Stundenplan öffnen',
|
||||
@@ -201,24 +162,33 @@ class _TimetableState extends State<Timetable> {
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_ViewingBanner(element: selected, onClose: _backToOwnPlan),
|
||||
if (subject case ElementTimetable(:final element))
|
||||
_ViewingBanner(element: element, onClose: _backToOwnPlan),
|
||||
Expanded(
|
||||
child: LoadableStateConsumer<ForeignTimetableBloc, TimetableState>(
|
||||
// Foreign plans never carry custom events, so unlike the own-plan
|
||||
// view we must not require `customEvents` here.
|
||||
isReady: (state) =>
|
||||
state.rooms != null &&
|
||||
state.subjects != null &&
|
||||
state.schoolHolidays != null,
|
||||
child: LoadableStateConsumer<B, TimetableState>(
|
||||
// Without this predicate the consumer treats the freshly-
|
||||
// initialised empty TimetableState as "has content" and only
|
||||
// shows the error bar on top — but the calendar view collapses
|
||||
// to `SizedBox.shrink()` while the reference data is missing,
|
||||
// leaving the user with a blank screen.
|
||||
isReady: (state) => state.isReady(
|
||||
needsCustomEvents: subject.supportsCustomEvents,
|
||||
),
|
||||
child: (state, _) => TimetableCalendarView(
|
||||
key: _calendarKey,
|
||||
state: state,
|
||||
onWeekChanged: bloc.changeWeek,
|
||||
onAppointmentTap: (apt) =>
|
||||
AppointmentDetailsDispatcher.show(context, state, apt),
|
||||
customEvents: const [],
|
||||
showClassInsteadOfTeacher:
|
||||
selected.type == TimetableElementType.teacher,
|
||||
onAppointmentTap: (apt) => AppointmentDetailsDispatcher.show(
|
||||
context,
|
||||
state,
|
||||
apt,
|
||||
canEditSubjectColor: policy.canEditSubjectColors,
|
||||
),
|
||||
onCreateEvent: policy.canManageCustomEvents
|
||||
? _onCreateEventAt
|
||||
: null,
|
||||
customEvents: state.customEvents?.events ?? const [],
|
||||
showClassInsteadOfTeacher: policy.showClassInsteadOfTeacher,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -233,6 +203,23 @@ class _TimetableState extends State<Timetable> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown instead of a plan when the session has none, i.e. a guardian whose
|
||||
/// children are not known (yet).
|
||||
class _NoTimetableView extends StatelessWidget {
|
||||
const _NoTimetableView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final capabilities = context.watch<CapabilitiesCubit>().state;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Stunden & Vertretungsplan')),
|
||||
body: capabilities.loaded
|
||||
? const NoChildrenPlaceholder()
|
||||
: const Center(child: AppProgressIndicator.large()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slim banner shown at the top of the timetable while a foreign element's plan
|
||||
/// is being viewed. Displays which element is shown, lets the user star it, and
|
||||
/// offers a one-tap return to the own plan.
|
||||
|
||||
Reference in New Issue
Block a user