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
@@ -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;
}