Files
Client/lib/state/app/modules/timetable/bloc/timetable_bloc.dart
T

315 lines
9.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:developer';
import '../../../../../api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart';
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<
TimetableEvent,
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
/// checks and actually hit the network. Cleared at the top of [gatherData].
bool _forceRenew = false;
@override
void retry() {
_forceRenew = true;
super.retry();
}
@override
TimetableRepository repository() => TimetableRepository();
@override
TimetableState fromNothing() {
final reference = DateTime.now().addDays(2);
return TimetableState(
startDate: _startOfWeek(reference),
endDate: _endOfWeek(reference),
);
}
/// Persisted state may carry a stale `startDate`/`endDate` from the user's
/// last view. Reset on every cold start so the calendar always mounts on
/// the current week, not on whatever week the user closed the app on.
@override
TimetableState fromStorage(Map<String, dynamic> json) {
final stored = TimetableState.fromJson(json);
final reference = DateTime.now().addDays(2);
return stored.copyWith(
startDate: _startOfWeek(reference),
endDate: _endOfWeek(reference),
accessibleStartDate: null,
accessibleEndDate: null,
);
}
@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;
Object? firstError;
void recordError(Object e) {
firstError ??= e;
}
await Future.wait([
_loadCurrentWeek(
initial.startDate,
initial.endDate,
onError: recordError,
),
_loadStaticReferenceData(onError: recordError, renew: renew),
if (subject.supportsCustomEvents)
_loadCustomEvents(onError: recordError, renew: renew),
]);
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();
/// 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();
}
/// Setzt die persönliche Farbe eines Fachs (Schlüssel: Fach-Kürzel) und lädt
/// die Fächer neu, damit die eingebettete Farbe im Kalender wirkt.
Future<void> setSubjectColor(String subjectShort, String color) async {
await repo.data.setSubjectColor(subjectShort, color);
await _refreshSubjects();
}
Future<void> clearSubjectColor(String subjectShort) async {
await repo.data.removeSubjectColor(subjectShort);
await _refreshSubjects();
}
Future<void> _refreshSubjects() async {
final subjects = await repo.data.getSubjects(renew: true);
add(
DataGathered(
(s) => s.copyWith(subjects: subjects, dataVersion: s.dataVersion + 1),
),
);
}
Future<void> _loadCurrentWeek(
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
}) async {
final requestStart = DateTime.now();
_lastWeekRequestStart = requestStart;
try {
final week = await repo.data.getWeek(
subject,
startDate,
endDate,
onError: onError,
);
if (_lastWeekRequestStart.isAfter(requestStart)) return;
_writeWeekToCache(startDate, week);
} catch (e) {
log('getWeek error for $startDate$endDate: $e');
onError?.call(e);
}
}
Future<void> _loadStaticReferenceData({
void Function(Object)? onError,
bool renew = false,
}) async {
try {
final (rooms, subjects, schoolHolidays, schoolyear) = await (
repo.data.getRooms(onError: onError, renew: renew),
repo.data.getSubjects(onError: onError, renew: renew),
repo.data.getSchoolHolidays(onError: onError, renew: renew),
repo.data.getCurrentSchoolyear(onError: onError, renew: renew),
).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(renew: renew);
add(
Emit(
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
),
);
} catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI layer.
}
}
Future<void> _loadCustomEvents({
void Function(Object)? onError,
bool renew = false,
}) async {
try {
await CustomEventsMigration.runOnce();
final events = await repo.data.getCustomEvents(
renew: renew,
onError: onError,
);
add(
Emit(
(s) =>
s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
),
);
} catch (e) {
onError?.call(e);
}
}
Future<void> _refreshCustomEvents() async {
final events = await repo.data.getCustomEvents(renew: true);
add(
DataGathered(
(s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
),
);
}
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
.getWeek(subject, 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);
}
}
/// 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});
}