added guardian login with views for their assigned childs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user