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

282 lines
8.6 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 'package:intl/intl.dart';
import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
import '../../../../../api/webuntis/webuntis_error.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 'timetable_event.dart';
import 'timetable_state.dart';
class TimetableBloc
extends
LoadableHydratedBloc<
TimetableEvent,
TimetableState,
TimetableRepository
> {
static const Duration _weekSpan = Duration(days: 7);
static final DateFormat _weekKeyFormat = DateFormat('yyyyMMdd');
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().add(const Duration(days: 2));
return TimetableState(
startDate: _startOfWeek(reference),
endDate: _endOfWeek(reference),
);
}
@override
TimetableState fromStorage(Map<String, dynamic> json) =>
TimetableState.fromJson(json);
@override
Map<String, dynamic>? toStorage(TimetableState state) => state.toJson();
@override
Future<void> gatherData() async {
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,
renew: renew,
),
_loadStaticReferenceData(onError: recordError, renew: renew),
_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().add(const Duration(days: 2));
changeWeek(_startOfWeek(reference), _endOfWeek(reference));
}
void refresh() => fetch();
Future<void> addCustomEvent(CustomTimetableEvent event) async {
await repo.data.addCustomEvent(event);
await _refreshCustomEvents();
}
Future<void> updateCustomEvent(String id, CustomTimetableEvent event) async {
await repo.data.updateCustomEvent(id, event);
await _refreshCustomEvents();
}
Future<void> removeCustomEvent(String id) async {
await repo.data.removeCustomEvent(id);
await _refreshCustomEvents();
}
Future<void> _loadCurrentWeek(
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(
startDate,
endDate,
onError: onError,
renew: renew,
);
if (_lastWeekRequestStart.isAfter(requestStart)) return;
_writeWeekToCache(startDate, week);
} on WebuntisError catch (e) {
if (e.code == _outOfRangeErrorCode) {
_narrowAccessibleRange(startDate, endDate);
// Out-of-range is expected when the user scrolls into territory
// Webuntis doesn't grant access to — surface to UI as a normal
// empty week instead of letting the loadable state escalate it
// into a red error screen.
return;
}
log(
'Webuntis getWeek error: code=${e.code} message="${e.message}" '
'for $startDate$endDate',
);
onError?.call(e);
} catch (e) {
onError?.call(e);
}
}
/// Webuntis returns this for weeks the user has no access to (typically
/// before the active enrolment / after a teacher's planning window).
static const int _outOfRangeErrorCode = -7004;
/// Pulls the calendar's permitted scroll range inward based on a denied
/// week. We don't know the exact cutoff — only that *this* week is out
/// of reach — so we always pick the tighter of the existing bound and
/// the newly discovered one. Pre-now denials shrink the lower bound,
/// post-now denials the upper.
void _narrowAccessibleRange(DateTime startDate, DateTime endDate) {
final now = DateTime.now();
final isPast = endDate.isBefore(now);
add(
Emit((s) {
if (isPast) {
final candidate = endDate.add(const Duration(days: 1));
final current = s.accessibleStartDate;
if (current != null && !candidate.isAfter(current)) return s;
return s.copyWith(accessibleStartDate: candidate);
}
// Treat anything not strictly past as a forward-direction denial,
// including the rare case where startDate == now.
final candidate = startDate.subtract(const Duration(days: 1));
final current = s.accessibleEndDate;
if (current != null && !candidate.isBefore(current)) return s;
return s.copyWith(accessibleEndDate: candidate);
}),
);
}
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 {
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.subtract(_weekSpan), end.subtract(_weekSpan));
_prefetchWeek(start.add(_weekSpan), end.add(_weekSpan));
}
void _prefetchWeek(DateTime start, DateTime end) {
repo.data
.getWeek(start, end)
.then((week) => _writeWeekToCache(start, week))
.catchError((_) {});
}
void _writeWeekToCache(DateTime weekStart, GetTimetableResponse week) {
final key = _weekKeyFormat.format(weekStart);
add(
Emit((s) {
final updated = Map<String, GetTimetableResponse>.of(s.weekCache);
updated[key] = week;
return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1);
}),
);
}
static DateTime _startOfWeek(DateTime reference) {
final monday = reference.subtract(Duration(days: reference.weekday - 1));
return DateTime(monday.year, monday.month, monday.day);
}
static DateTime _endOfWeek(DateTime reference) {
final friday = reference.add(
Duration(days: DateTime.daysPerWeek - reference.weekday - 2),
);
return DateTime(friday.year, friday.month, friday.day);
}
}