added guardian login with views for their assigned childs

This commit is contained in:
2026-09-20 11:11:58 +02:00
parent e4e2b1a4fb
commit 67c935c05b
117 changed files with 4784 additions and 1514 deletions
+2 -2
View File
@@ -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);
}
}
+82 -6
View File
@@ -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(
+23 -18
View File
@@ -10,7 +10,8 @@ import '../../api/marianumconnect/auth/device_token_name.dart';
import '../../api/marianumconnect/auth/token_storage.dart';
import '../../api/marianumconnect/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),
),
),
);
}
+1 -19
View File
@@ -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});
+52 -111
View File
@@ -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();
},
);
}
}
+34 -20
View File
@@ -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,
],
],
),
);
}
}
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../api/marianumcloud/talk/chat/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,
+3 -7
View File
@@ -8,9 +8,9 @@ import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dar
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/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),
+91 -104
View File
@@ -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.