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
+17 -3
View File
@@ -3,8 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../../../access/access_requirement.dart';
import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import '../../../routing/app_routes.dart';
import '../../../session/session.dart';
import '../../../session/session_manager.dart';
import '../../../storage/modules_settings.dart';
import '../../../view/pages/absence_report/absence_report_view.dart';
import '../../../view/pages/files/files.dart';
@@ -38,6 +41,16 @@ class AppModule {
required this.create,
});
/// Backend identities each module needs. Modules without an entry work for
/// every session.
static const Map<Modules, Set<AccessRequirement>> requirements = {
Modules.talk: {AccessRequirement.nextcloud},
Modules.files: {AccessRequirement.nextcloud},
};
static bool isAvailableFor(Modules module, Session? session) =>
(requirements[module] ?? const {}).areMetBy(session);
static Map<Modules, AppModule> modules(
BuildContext context, {
bool showFiltered = false,
@@ -146,6 +159,9 @@ class AppModule {
),
};
final session = SessionManager().current;
available.removeWhere((key, _) => !isAvailableFor(key, session));
if (!showFiltered) {
available.removeWhere(
(key, value) =>
@@ -177,9 +193,7 @@ class AppModule {
for (final missing in Modules.values) {
if (!seen.add(missing)) continue;
var insertAt = 0;
for (final predecessor in Modules.values.takeWhile(
(m) => m != missing,
)) {
for (final predecessor in Modules.values.takeWhile((m) => m != missing)) {
final pos = order.indexOf(predecessor);
if (pos >= insertAt) insertAt = pos + 1;
}
@@ -5,6 +5,8 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/demo/data/demo_capabilities.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart';
import '../../../../../session/session.dart';
import '../../../../../session/session_manager.dart';
import 'capabilities_state.dart';
/// Holds the current user's mobile capability flags. Hydrated so the last
@@ -21,17 +23,17 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
int? get timetableFutureDays => state.timetableFutureDays;
/// Teacher accounts get the class shown on timetable tiles instead of their
/// own name (see TimetableAppointmentFactory.showClassInsteadOfTeacher).
bool get isTeacher => state.userType == 'TEACHER';
/// Refreshes capabilities from the server. On any failure (endpoint not yet
/// live, network error, 4xx) the previously hydrated flags are kept but the
/// state is marked `loaded` — a failed fetch never silently grants a
/// capability, and an offline launch keeps whatever was cached.
Future<void> load() async {
if (DemoMode.active) {
emit(DemoCapabilities.state());
emit(
SessionManager().current is GuardianSession
? DemoCapabilities.guardianState()
: DemoCapabilities.state(),
);
return;
}
try {
@@ -43,6 +45,7 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
timetablePastDays: response.timetablePastDays,
timetableFutureDays: response.timetableFutureDays,
userType: response.userType,
children: response.children,
loaded: true,
),
);
@@ -1,10 +1,15 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../../access/user_role.dart';
import '../../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
part 'capabilities_state.freezed.dart';
part 'capabilities_state.g.dart';
@freezed
abstract class CapabilitiesState with _$CapabilitiesState {
const CapabilitiesState._();
const factory CapabilitiesState({
@Default(false) bool viewForeignTimetables,
@Default(false) bool pushNotifications,
@@ -12,8 +17,10 @@ abstract class CapabilitiesState with _$CapabilitiesState {
// client-side clamp; the (server-narrowed) school year alone governs.
int? timetablePastDays,
int? timetableFutureDays,
// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
// Wire value of the user type; read it through [role].
String? userType,
// Students a guardian may see; empty for all other accounts.
@Default(<GuardianChild>[]) List<GuardianChild> children,
// Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed".
@@ -22,4 +29,6 @@ abstract class CapabilitiesState with _$CapabilitiesState {
factory CapabilitiesState.fromJson(Map<String, Object?> json) =>
_$CapabilitiesStateFromJson(json);
UserRole get role => UserRole.parse(userType);
}
@@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'capabilities_state.dart';
@@ -9,19 +9,14 @@ part of 'capabilities_state.dart';
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$CapabilitiesState {
bool get viewForeignTimetables; bool get pushNotifications;// Days into the past/future the timetable may be scrolled. Null = no
// client-side clamp; the (server-narrowed) school year alone governs.
int? get timetablePastDays; int? get timetableFutureDays;// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
String? get userType;// Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed".
bool get loaded;
bool get viewForeignTimetables; bool get pushNotifications; int? get timetablePastDays; int? get timetableFutureDays; String? get userType; List<GuardianChild> get children; bool get loaded;
/// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -34,16 +29,21 @@ $CapabilitiesStateCopyWith<CapabilitiesState> get copyWith => _$CapabilitiesStat
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&(identical(other.loaded, loaded) || other.loaded == loaded));
final _this = this as CapabilitiesState;
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, _this.viewForeignTimetables) || other.viewForeignTimetables == _this.viewForeignTimetables)&&(identical(other.pushNotifications, _this.pushNotifications) || other.pushNotifications == _this.pushNotifications)&&(identical(other.timetablePastDays, _this.timetablePastDays) || other.timetablePastDays == _this.timetablePastDays)&&(identical(other.timetableFutureDays, _this.timetableFutureDays) || other.timetableFutureDays == _this.timetableFutureDays)&&(identical(other.userType, _this.userType) || other.userType == _this.userType)&&const DeepCollectionEquality().equals(other.children, _this.children)&&(identical(other.loaded, _this.loaded) || other.loaded == _this.loaded));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded);
int get hashCode {
final _this = this as CapabilitiesState;
return Object.hash(runtimeType,_this.viewForeignTimetables,_this.pushNotifications,_this.timetablePastDays,_this.timetableFutureDays,_this.userType,const DeepCollectionEquality().hash(_this.children),_this.loaded);
}
@override
String toString() {
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)';
final _this = this as CapabilitiesState;
return 'CapabilitiesState(viewForeignTimetables: ${_this.viewForeignTimetables}, pushNotifications: ${_this.pushNotifications}, timetablePastDays: ${_this.timetablePastDays}, timetableFutureDays: ${_this.timetableFutureDays}, userType: ${_this.userType}, children: ${_this.children}, loaded: ${_this.loaded})';
}
@@ -54,7 +54,7 @@ abstract mixin class $CapabilitiesStateCopyWith<$Res> {
factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl;
@useResult
$Res call({
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded
});
@@ -71,14 +71,15 @@ class _$CapabilitiesStateCopyWithImpl<$Res>
/// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? loaded = null,}) {
return _then(_self.copyWith(
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? children = null,Object? loaded = null,}) {
return _then(CapabilitiesState(
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable
as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable
as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable
as String?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
as String?,children: null == children ? _self.children : children // ignore: cast_nullable_to_non_nullable
as List<GuardianChild>,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
as bool,
));
}
@@ -164,10 +165,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _CapabilitiesState() when $default != null:
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _:
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded);case _:
return orElse();
}
@@ -185,10 +186,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded) $default,) {final _that = this;
switch (_that) {
case _CapabilitiesState():
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _:
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded);case _:
throw StateError('Unexpected subclass');
}
@@ -205,10 +206,10 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded)? $default,) {final _that = this;
switch (_that) {
case _CapabilitiesState() when $default != null:
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.loaded);case _:
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timetablePastDays,_that.timetableFutureDays,_that.userType,_that.children,_that.loaded);case _:
return null;
}
@@ -219,21 +220,22 @@ return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.timeta
/// @nodoc
@JsonSerializable()
class _CapabilitiesState implements CapabilitiesState {
const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, this.loaded = false});
class _CapabilitiesState extends CapabilitiesState {
const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.timetablePastDays, this.timetableFutureDays, this.userType, List<GuardianChild> children = const <GuardianChild>[], this.loaded = false}): _children = children,super._();
factory _CapabilitiesState.fromJson(Map<String, dynamic> json) => _$CapabilitiesStateFromJson(json);
@override@JsonKey() final bool viewForeignTimetables;
@override@JsonKey() final bool pushNotifications;
// Days into the past/future the timetable may be scrolled. Null = no
// client-side clamp; the (server-narrowed) school year alone governs.
@override final int? timetablePastDays;
@override final int? timetableFutureDays;
// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'); null = unknown.
@override final String? userType;
// Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed".
final List<GuardianChild> _children;
@override@JsonKey() List<GuardianChild> get children {
if (_children is EqualUnmodifiableListView) return _children;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_children);
}
@override@JsonKey() final bool loaded;
/// Create a copy of CapabilitiesState
@@ -249,16 +251,18 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&(identical(other.loaded, loaded) || other.loaded == loaded));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.timetablePastDays, timetablePastDays) || other.timetablePastDays == timetablePastDays)&&(identical(other.timetableFutureDays, timetableFutureDays) || other.timetableFutureDays == timetableFutureDays)&&(identical(other.userType, userType) || other.userType == userType)&&const DeepCollectionEquality().equals(other.children, _children)&&(identical(other.loaded, loaded) || other.loaded == loaded));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,loaded);
int get hashCode {
return Object.hash(runtimeType,viewForeignTimetables,pushNotifications,timetablePastDays,timetableFutureDays,userType,const DeepCollectionEquality().hash(_children),loaded);
}
@override
String toString() {
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, loaded: $loaded)';
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, timetablePastDays: $timetablePastDays, timetableFutureDays: $timetableFutureDays, userType: $userType, children: $children, loaded: $loaded)';
}
@@ -269,7 +273,7 @@ abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesS
factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl;
@override @useResult
$Res call({
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, bool loaded
bool viewForeignTimetables, bool pushNotifications, int? timetablePastDays, int? timetableFutureDays, String? userType, List<GuardianChild> children, bool loaded
});
@@ -286,14 +290,15 @@ class __$CapabilitiesStateCopyWithImpl<$Res>
/// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? loaded = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? timetablePastDays = freezed,Object? timetableFutureDays = freezed,Object? userType = freezed,Object? children = null,Object? loaded = null,}) {
return _then(_CapabilitiesState(
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
as bool,timetablePastDays: freezed == timetablePastDays ? _self.timetablePastDays : timetablePastDays // ignore: cast_nullable_to_non_nullable
as int?,timetableFutureDays: freezed == timetableFutureDays ? _self.timetableFutureDays : timetableFutureDays // ignore: cast_nullable_to_non_nullable
as int?,userType: freezed == userType ? _self.userType : userType // ignore: cast_nullable_to_non_nullable
as String?,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
as String?,children: null == children ? _self._children : children // ignore: cast_nullable_to_non_nullable
as List<GuardianChild>,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
as bool,
));
}
@@ -13,6 +13,11 @@ _CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) =>
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
userType: json['userType'] as String?,
children:
(json['children'] as List<dynamic>?)
?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
.toList() ??
const <GuardianChild>[],
loaded: json['loaded'] as bool? ?? false,
);
@@ -23,5 +28,6 @@ Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) =>
'timetablePastDays': instance.timetablePastDays,
'timetableFutureDays': instance.timetableFutureDays,
'userType': instance.userType,
'children': instance.children,
'loaded': instance.loaded,
};
@@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -43,6 +44,11 @@ class ChatListBloc
});
}
@override
Set<AccessRequirement> get requirements => const {
AccessRequirement.nextcloud,
};
@override
ChatListRepository repository() => ChatListRepository();
@@ -73,6 +79,7 @@ class ChatListBloc
}
Future<void> refresh({bool renew = true, bool silent = false}) async {
if (!requirementsMet) return;
if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError;
try {
@@ -0,0 +1,33 @@
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
/// The child a guardian is currently looking at. Shared by every module that
/// shows per-child data (timetable, absence report, later messages), so
/// switching the child in one place switches it everywhere.
class ChildSelectionCubit extends HydratedCubit<String?> {
ChildSelectionCubit() : super(null);
void select(String childId) => emit(childId);
Future<void> reset() async => emit(null);
@override
String? fromJson(Map<String, dynamic> json) => json['childId'] as String?;
@override
Map<String, dynamic>? toJson(String? state) => {'childId': state};
}
/// The selected child if it is still linked, otherwise the first one. Null
/// when there are no children.
GuardianChild? effectiveChild(
List<GuardianChild> children,
String? selectedId,
) {
if (children.isEmpty) return null;
for (final child in children) {
if (child.id == selectedId) return child;
}
return children.first;
}
@@ -1,204 +0,0 @@
import 'dart:developer';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../extensions/date_time.dart';
import '../../../infrastructure/loadable_state/loadable_state.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../../timetable/bloc/timetable_event.dart';
import '../../timetable/bloc/timetable_state.dart';
import '../repository/foreign_timetable_repository.dart';
/// Drives a foreign element's timetable. Mirrors `TimetableBloc`'s week-loading
/// and navigation but loads weeks from the element endpoint, carries no custom
/// events, and does not persist (page-scoped, recreated per element). Reuses
/// [TimetableState] verbatim so the render pipeline is unchanged; `customEvents`
/// stays null (the foreign view's `isReady` predicate ignores it).
class ForeignTimetableBloc
extends
LoadableHydratedBloc<
TimetableEvent,
TimetableState,
ForeignTimetableRepository
> {
final TimetableElementType type;
// Named `elementId` rather than `id` to avoid shadowing HydratedMixin's
// `String get id` (the storage key), which a plain `int id` would illegally
// override.
final int elementId;
final String title;
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
ForeignTimetableBloc({
required this.type,
required this.elementId,
required this.title,
});
@override
ForeignTimetableRepository repository() => ForeignTimetableRepository();
@override
TimetableState fromNothing() {
final reference = DateTime.now().addDays(2);
return TimetableState(
startDate: _startOfWeek(reference),
endDate: _endOfWeek(reference),
);
}
// Persistence disabled: page-scoped and element-specific, nothing worth
// restoring. toJson returns null so nothing is written; fromJson starts fresh.
@override
Map<String, dynamic>? toJson(LoadableState<TimetableState> state) => null;
@override
LoadableState<TimetableState> fromJson(Map<String, dynamic> json) =>
const LoadableState(
isLoading: true,
data: null,
lastFetch: null,
reFetch: null,
error: null,
);
@override
TimetableState fromStorage(Map<String, dynamic> json) => fromNothing();
@override
Map<String, dynamic>? toStorage(TimetableState state) => null;
@override
Future<void> gatherData() async {
final initial = innerState ?? fromNothing();
Object? firstError;
void recordError(Object e) {
firstError ??= e;
}
await Future.wait([
_loadCurrentWeek(initial.startDate, initial.endDate, onError: recordError),
_loadStaticReferenceData(onError: recordError),
]);
if (firstError != null) throw firstError!;
add(DataGathered((s) => s));
_prefetchAdjacentWeeks(initial.startDate, initial.endDate);
}
void changeWeek(DateTime startDate, DateTime endDate) {
final current = innerState ?? fromNothing();
if (current.startDate == startDate && current.endDate == endDate) return;
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
_loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
}
void resetWeek() {
final reference = DateTime.now().addDays(2);
changeWeek(_startOfWeek(reference), _endOfWeek(reference));
}
void refresh() => fetch();
Future<void> _loadCurrentWeek(
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
}) async {
final requestStart = DateTime.now();
_lastWeekRequestStart = requestStart;
try {
final week = await repo.data.getElementWeek(
type,
elementId,
startDate,
endDate,
onError: onError,
);
if (_lastWeekRequestStart.isAfter(requestStart)) return;
_writeWeekToCache(startDate, week);
} catch (e) {
log('getElementWeek error for $startDate$endDate: $e');
onError?.call(e);
}
}
Future<void> _loadStaticReferenceData({
void Function(Object)? onError,
}) async {
try {
final (rooms, subjects, schoolHolidays, schoolyear) = await (
repo.data.getRooms(onError: onError),
repo.data.getSubjects(onError: onError),
repo.data.getSchoolHolidays(onError: onError),
repo.data.getCurrentSchoolyear(onError: onError),
).wait;
add(
Emit(
(s) => s.copyWith(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
dataVersion: s.dataVersion + 1,
),
),
);
} catch (e) {
onError?.call(e);
}
try {
final timegrid = await repo.data.getTimegrid();
add(
Emit(
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
),
);
} catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI.
}
}
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
_prefetchWeek(start.subtractDays(7), end.subtractDays(7));
_prefetchWeek(start.addDays(7), end.addDays(7));
}
void _prefetchWeek(DateTime start, DateTime end) {
repo.data
.getElementWeek(type, elementId, start, end)
.then((week) => _writeWeekToCache(start, week))
.catchError((_) {});
}
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
final key = weekStart.weekKey();
add(
Emit((s) {
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
updated[key] = week;
return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1);
}),
);
}
static DateTime _startOfWeek(DateTime reference) {
final monday = reference.subtractDays(reference.weekday - 1);
return DateTime(monday.year, monday.month, monday.day);
}
static DateTime _endOfWeek(DateTime reference) {
final friday = reference.addDays(
DateTime.daysPerWeek - reference.weekday - 2,
);
return DateTime(friday.year, friday.month, friday.day);
}
}
@@ -1,64 +0,0 @@
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../timetable/data_provider/timetable_data_provider.dart';
/// Data access for a foreign element's timetable. The week comes from the
/// element-specific endpoint; all reference data (rooms/subjects/holidays/
/// school year/timegrid) is school-wide, so it delegates to the existing
/// [TimetableDataProvider] (which caches it). Custom events are intentionally
/// absent — they are user-private.
class ForeignTimetableDataProvider {
final TimetableDataProvider _base;
ForeignTimetableDataProvider([TimetableDataProvider? base])
: _base = base ?? TimetableDataProvider();
Future<TimetableGetWeekResponse> getElementWeek(
TimetableElementType type,
int id,
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
}) async {
try {
return await TimetableGetElementWeek().run(
type: type,
id: id,
from: startDate,
until: endDate,
);
} catch (e) {
onError?.call(e);
rethrow;
}
}
Future<TimetableGetRoomsResponse> getRooms({
void Function(Object)? onError,
bool renew = false,
}) => _base.getRooms(onError: onError, renew: renew);
Future<TimetableGetSubjectsResponse> getSubjects({
void Function(Object)? onError,
bool renew = false,
}) => _base.getSubjects(onError: onError, renew: renew);
Future<TimetableGetHolidaysResponse> getSchoolHolidays({
void Function(Object)? onError,
bool renew = false,
}) => _base.getSchoolHolidays(onError: onError, renew: renew);
Future<TimetableGetSchoolyearResponse> getCurrentSchoolyear({
void Function(Object)? onError,
bool renew = false,
}) => _base.getCurrentSchoolyear(onError: onError, renew: renew);
Future<TimetableGetTimegridResponse> getTimegrid({bool renew = false}) =>
_base.getTimegrid(renew: renew);
}
@@ -1,12 +0,0 @@
import '../../../infrastructure/repository/repository.dart';
import '../../timetable/bloc/timetable_state.dart';
import '../data_provider/foreign_timetable_data_provider.dart';
class ForeignTimetableRepository extends Repository<TimetableState> {
final ForeignTimetableDataProvider _provider;
ForeignTimetableRepository([ForeignTimetableDataProvider? provider])
: _provider = provider ?? ForeignTimetableDataProvider();
ForeignTimetableDataProvider get data => _provider;
}
@@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/demo/data/demo_capabilities.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart';
import '../../../../../session/session_manager.dart';
import 'nextcloud_capabilities_state.dart';
/// Holds the current user's Nextcloud `files_sharing` capabilities. Hydrated so
@@ -59,6 +60,7 @@ class NextcloudCapabilitiesCubit
/// Refreshes capabilities from the server. On any failure the previously
/// hydrated flags are kept but the state is marked `loaded`.
Future<void> load() async {
if (!SessionManager().hasNextcloud) return;
if (DemoMode.active) {
emit(DemoNextcloudCapabilities.state());
return;
@@ -4,12 +4,17 @@ import '../../../../../api/marianumconnect/queries/timetable_custom_events/custo
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../../extensions/date_time.dart';
import '../../../infrastructure/loadable_state/loadable_state.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/timetable_repository.dart';
import '../subject/timetable_subject.dart';
import 'timetable_event.dart';
import 'timetable_state.dart';
/// Drives one [TimetableSubject]'s plan. The same class serves the own plan
/// and foreign element plans; everything subject-specific (endpoint,
/// persistence, custom events) is derived from [subject].
class TimetableBloc
extends
LoadableHydratedBloc<
@@ -17,6 +22,13 @@ class TimetableBloc
TimetableState,
TimetableRepository
> {
final TimetableSubject subject;
TimetableBloc({required this.subject});
@override
String get id => subject.storageId;
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
/// Set by [retry] to force the next [gatherData] to bypass cache freshness
@@ -59,8 +71,25 @@ class TimetableBloc
@override
Map<String, dynamic>? toStorage(TimetableState state) => state.toJson();
@override
Map<String, dynamic>? toJson(LoadableState<TimetableState> state) =>
subject.persistent ? super.toJson(state) : null;
@override
LoadableState<TimetableState> fromJson(Map<String, dynamic> json) =>
subject.persistent
? super.fromJson(json)
: const LoadableState(
isLoading: true,
data: null,
lastFetch: null,
reFetch: null,
error: null,
);
@override
Future<void> gatherData() async {
if (subject is NoTimetable) return;
final initial = innerState ?? fromNothing();
final renew = _forceRenew;
_forceRenew = false;
@@ -75,10 +104,10 @@ class TimetableBloc
initial.startDate,
initial.endDate,
onError: recordError,
renew: renew,
),
_loadStaticReferenceData(onError: recordError, renew: renew),
_loadCustomEvents(onError: recordError, renew: renew),
if (subject.supportsCustomEvents)
_loadCustomEvents(onError: recordError, renew: renew),
]);
if (firstError != null) throw firstError!;
@@ -102,17 +131,28 @@ class TimetableBloc
void refresh() => fetch();
/// Custom events belong to the signed-in user's own plan only — never to a
/// foreign plan or a guardian's view of a child.
void _requireCustomEvents() {
if (!subject.supportsCustomEvents) {
throw StateError('Custom events are not available for $subject');
}
}
Future<void> addCustomEvent(CustomTimetableEvent event) async {
_requireCustomEvents();
await repo.data.addCustomEvent(event);
await _refreshCustomEvents();
}
Future<void> updateCustomEvent(String id, CustomTimetableEvent event) async {
_requireCustomEvents();
await repo.data.updateCustomEvent(id, event);
await _refreshCustomEvents();
}
Future<void> removeCustomEvent(String id) async {
_requireCustomEvents();
await repo.data.removeCustomEvent(id);
await _refreshCustomEvents();
}
@@ -142,16 +182,15 @@ class TimetableBloc
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
bool renew = false,
}) async {
final requestStart = DateTime.now();
_lastWeekRequestStart = requestStart;
try {
final week = await repo.data.getWeek(
subject,
startDate,
endDate,
onError: onError,
renew: renew,
);
if (_lastWeekRequestStart.isAfter(requestStart)) return;
_writeWeekToCache(startDate, week);
@@ -237,7 +276,7 @@ class TimetableBloc
void _prefetchWeek(DateTime start, DateTime end) {
repo.data
.getWeek(start, end)
.getWeek(subject, start, end)
.then((week) => _writeWeekToCache(start, week))
.catchError((_) {});
}
@@ -265,3 +304,11 @@ class TimetableBloc
return DateTime(friday.year, friday.month, friday.day);
}
}
/// Type token for a page-scoped plan (e.g. a foreign element). Carries no
/// logic of its own; the distinct type keeps a page-local provider from
/// shadowing the app-wide [TimetableBloc] that sheets and root-navigator pages
/// (subject colours, custom events) read.
final class ScopedTimetableBloc extends TimetableBloc {
ScopedTimetableBloc({required super.subject});
}
@@ -40,9 +40,12 @@ abstract class TimetableState with _$TimetableState {
Iterable<McTimetableEntry> getAllKnownLessons() =>
weekCache.values.expand((response) => response.entries);
bool get hasReferenceData =>
/// Whether the calendar has everything it needs to render. Custom events
/// only exist for subjects that support them; requiring them elsewhere would
/// keep foreign plans loading forever.
bool isReady({required bool needsCustomEvents}) =>
rooms != null &&
subjects != null &&
schoolHolidays != null &&
customEvents != null;
(!needsCustomEvents || customEvents != null);
}
@@ -4,6 +4,8 @@ import '../../../../../api/marianumconnect/queries/timetable_custom_events/timet
import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_cache.dart';
import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_remove.dart';
import '../../../../../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_update.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_child_week/timetable_get_child_week.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
@@ -21,19 +23,43 @@ import '../../../../../api/marianumconnect/queries/timetable_subject_colors/time
import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../../../../../api/request_cache.dart';
import '../subject/timetable_subject.dart';
/// Pulls the timetable from the Marianum-Connect mobile API. Each endpoint is
/// its own HTTP call; this provider exposes the lazy futures so the bloc can
/// chain them without seeing the dio layer.
/// chain them without seeing the dio layer. Only the week depends on the
/// [TimetableSubject]; the reference data is school-wide.
class TimetableDataProvider {
/// The endpoint serving [subject]'s week. Shared with the widget background
/// isolate, which has no bloc.
static Future<TimetableGetWeekResponse> fetchWeek(
TimetableSubject subject, {
required DateTime from,
required DateTime until,
}) => switch (subject) {
OwnTimetable() => TimetableGetWeek().run(from: from, until: until),
ElementTimetable(:final element) => TimetableGetElementWeek().run(
type: element.type,
id: element.id,
from: from,
until: until,
),
ChildTimetable(:final childId) => TimetableGetChildWeek().run(
childId: childId,
from: from,
until: until,
),
NoTimetable() => throw StateError('No timetable subject'),
};
Future<TimetableGetWeekResponse> getWeek(
TimetableSubject subject,
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
bool renew = false,
}) async {
try {
return await TimetableGetWeek().run(from: startDate, until: endDate);
return await fetchWeek(subject, from: startDate, until: endDate);
} catch (e) {
onError?.call(e);
rethrow;
@@ -0,0 +1,54 @@
import '../../../../../access/user_role.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../capabilities/bloc/capabilities_state.dart';
import '../subject/timetable_subject.dart';
/// What the timetable view offers for a given subject. Resolved in one place
/// so the view never branches on roles or subject types itself.
class TimetablePolicy {
final bool canManageCustomEvents;
final bool canEditSubjectColors;
final bool showClassInsteadOfTeacher;
final bool canOpenForeign;
const TimetablePolicy({
required this.canManageCustomEvents,
required this.canEditSubjectColors,
required this.showClassInsteadOfTeacher,
required this.canOpenForeign,
});
static TimetablePolicy resolve({
required TimetableSubject subject,
required CapabilitiesState capabilities,
}) => switch (subject) {
OwnTimetable() => TimetablePolicy(
canManageCustomEvents: true,
canEditSubjectColors: true,
showClassInsteadOfTeacher: capabilities.role == UserRole.teacher,
canOpenForeign: capabilities.viewForeignTimetables,
),
// Subject colours are the viewer's own, global setting; editing them from
// a foreign plan would not refresh that plan, so it is not offered there.
ElementTimetable(:final element) => TimetablePolicy(
canManageCustomEvents: false,
canEditSubjectColors: false,
showClassInsteadOfTeacher: element.type == TimetableElementType.teacher,
canOpenForeign: capabilities.viewForeignTimetables,
),
// Custom events are the child's private data; subject colours are the
// guardian's own and apply to this (primary) plan directly.
ChildTimetable() => TimetablePolicy(
canManageCustomEvents: false,
canEditSubjectColors: true,
showClassInsteadOfTeacher: false,
canOpenForeign: capabilities.viewForeignTimetables,
),
NoTimetable() => const TimetablePolicy(
canManageCustomEvents: false,
canEditSubjectColors: false,
showClassInsteadOfTeacher: false,
canOpenForeign: false,
),
};
}
@@ -0,0 +1,18 @@
import '../../../../../api/marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../../../../../session/session.dart';
import '../../children/child_selection_cubit.dart';
import '../subject/timetable_subject.dart';
/// Whose plan the timetable tab shows for the active session.
TimetableSubject resolvePrimarySubject({
required Session? session,
required List<GuardianChild> children,
required String? selectedChildId,
}) => switch (session) {
null => const NoTimetable(),
CredentialSession() => const OwnTimetable(),
GuardianSession() => switch (effectiveChild(children, selectedChildId)) {
null => const NoTimetable(),
final child => ChildTimetable(child.id),
},
};
@@ -0,0 +1,81 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../session/session_manager.dart';
import '../../account/bloc/account_bloc.dart';
import '../../account/bloc/account_state.dart';
import '../../capabilities/bloc/capabilities_cubit.dart';
import '../../capabilities/bloc/capabilities_state.dart';
import '../../children/child_selection_cubit.dart';
import '../bloc/timetable_bloc.dart';
import '../subject/timetable_subject.dart';
import 'primary_subject_resolver.dart';
/// Provides the app-wide [TimetableBloc] for the session's primary subject
/// (own plan, or the selected child for guardians) and swaps it for a fresh
/// instance when that subject changes.
///
/// Sits above MaterialApp so root-navigator pages (subject colours, custom
/// events) reach it. The widget subtree is kept on a swap — only the provided
/// instance changes, which BlocBuilder/BlocListener pick up — so switching
/// the child does not reset the navigation. A new instance per subject (rather
/// than retargeting one bloc) keeps late responses for the previous child out
/// of the new child's week cache.
class PrimaryTimetableScope extends StatefulWidget {
final Widget child;
const PrimaryTimetableScope({required this.child, super.key});
@override
State<PrimaryTimetableScope> createState() => _PrimaryTimetableScopeState();
}
class _PrimaryTimetableScopeState extends State<PrimaryTimetableScope> {
late TimetableBloc _bloc;
@override
void initState() {
super.initState();
_bloc = TimetableBloc(subject: _resolve());
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
TimetableSubject _resolve() => resolvePrimarySubject(
session: SessionManager().current,
children: context.read<CapabilitiesCubit>().state.children,
selectedChildId: context.read<ChildSelectionCubit>().state,
);
void _sync() {
final subject = _resolve();
if (subject == _bloc.subject) return;
final previous = _bloc;
setState(() => _bloc = TimetableBloc(subject: subject));
// Dependents re-subscribe during the next build; close afterwards.
WidgetsBinding.instance.addPostFrameCallback((_) => previous.close());
}
@override
Widget build(BuildContext context) => MultiBlocListener(
listeners: [
BlocListener<AccountBloc, AccountState>(
listenWhen: (a, b) => a.status != b.status,
listener: (_, _) => _sync(),
),
BlocListener<CapabilitiesCubit, CapabilitiesState>(
listenWhen: (a, b) => a.children != b.children,
listener: (_, _) => _sync(),
),
BlocListener<ChildSelectionCubit, String?>(listener: (_, _) => _sync()),
],
child: BlocProvider<TimetableBloc>.value(
value: _bloc,
child: widget.child,
),
);
}
@@ -0,0 +1,110 @@
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
/// Whose timetable a [TimetableBloc] shows. The render pipeline is identical
/// for every subject; only the week endpoint, persistence and the
/// user-private extras (custom events) differ.
sealed class TimetableSubject {
const TimetableSubject();
/// Suffix of the hydrated storage slot. Must be unique per subject so
/// subjects never overwrite each other's cached weeks.
String get storageId;
/// Whether the bloc keeps its state across app restarts.
bool get persistent;
/// Custom events are user-private and only exist for the own plan.
bool get supportsCustomEvents;
}
/// The signed-in user's own plan (`timetable/me`).
final class OwnTimetable extends TimetableSubject {
const OwnTimetable();
// Empty on purpose: keeps the pre-existing storage slot "TimetableBloc", so
// updating the app does not drop the cached weeks.
@override
String get storageId => '';
@override
bool get persistent => true;
@override
bool get supportsCustomEvents => true;
@override
bool operator ==(Object other) => other is OwnTimetable;
@override
int get hashCode => (OwnTimetable).hashCode;
}
/// A foreign element picked by the user (teacher, room, class, student).
final class ElementTimetable extends TimetableSubject {
final TimetableElementRef element;
const ElementTimetable(this.element);
@override
String get storageId => 'element-${element.type.name}-${element.id}';
@override
bool get persistent => false;
@override
bool get supportsCustomEvents => false;
@override
bool operator ==(Object other) =>
other is ElementTimetable &&
other.element.type == element.type &&
other.element.id == element.id;
@override
int get hashCode => Object.hash(element.type, element.id);
}
/// A guardian's child (`timetable/child/{id}`). Kept across restarts per
/// child, so switching between siblings shows the cached plan immediately.
final class ChildTimetable extends TimetableSubject {
final String childId;
const ChildTimetable(this.childId);
@override
String get storageId => 'child-$childId';
@override
bool get persistent => true;
@override
bool get supportsCustomEvents => false;
@override
bool operator ==(Object other) =>
other is ChildTimetable && other.childId == childId;
@override
int get hashCode => childId.hashCode;
}
/// No plan to show: signed out, or a guardian without (known) children. The
/// bloc loads nothing; the view explains why.
final class NoTimetable extends TimetableSubject {
const NoTimetable();
@override
String get storageId => 'none';
@override
bool get persistent => false;
@override
bool get supportsCustomEvents => false;
@override
bool operator ==(Object other) => other is NoTimetable;
@override
int get hashCode => (NoTimetable).hashCode;
}