282 lines
8.6 KiB
Dart
282 lines
8.6 KiB
Dart
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);
|
||
}
|
||
}
|