implemented foreign timetable support for students, teachers, rooms, and classes, including a searchable element picker with favorites support, introduced a capabilities system for feature gating, refactored the timetable UI into a reusable TimetableCalendarView component, and redesigned the chat input field with a unified emoji picker and integrated attachment actions.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.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 '../../timetable/bloc/timetable_event.dart';
|
||||
import '../../timetable/bloc/timetable_state.dart';
|
||||
import '../repository/foreign_timetable_repository.dart';
|
||||
|
||||
/// Drives a foreign element's timetable. Mirrors the week-loading and
|
||||
/// week-navigation logic of `TimetableBloc` but (a) loads weeks from the
|
||||
/// element endpoint, (b) carries no custom events, and (c) does not persist —
|
||||
/// it is created per opened page and recreated for every selected element.
|
||||
///
|
||||
/// It reuses [TimetableState] verbatim so the existing render pipeline works
|
||||
/// unchanged; `customEvents` simply stays null (the foreign view uses an
|
||||
/// `isReady` predicate that ignores it).
|
||||
class ForeignTimetableBloc
|
||||
extends
|
||||
LoadableHydratedBloc<
|
||||
TimetableEvent,
|
||||
TimetableState,
|
||||
ForeignTimetableRepository
|
||||
> {
|
||||
static final DateFormat _weekKeyFormat = DateFormat('yyyyMMdd');
|
||||
|
||||
final TimetableElementType type;
|
||||
// Named `elementId` rather than `id` to avoid shadowing HydratedMixin's
|
||||
// `String get id` (the storage key), which a plain `int id` would illegally
|
||||
// override.
|
||||
final int elementId;
|
||||
final String title;
|
||||
|
||||
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
ForeignTimetableBloc({
|
||||
required this.type,
|
||||
required this.elementId,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
ForeignTimetableRepository repository() => ForeignTimetableRepository();
|
||||
|
||||
@override
|
||||
TimetableState fromNothing() {
|
||||
final reference = DateTime.now().addDays(2);
|
||||
return TimetableState(
|
||||
startDate: _startOfWeek(reference),
|
||||
endDate: _endOfWeek(reference),
|
||||
);
|
||||
}
|
||||
|
||||
// Persistence is disabled: this bloc is page-scoped and element-specific, so
|
||||
// there is nothing worth restoring across launches. Returning null from
|
||||
// toJson means HydratedBloc never writes anything; fromJson ignores any
|
||||
// legacy payload and starts fresh.
|
||||
@override
|
||||
Map<String, dynamic>? toJson(LoadableState<TimetableState> state) => null;
|
||||
|
||||
@override
|
||||
LoadableState<TimetableState> fromJson(Map<String, dynamic> json) =>
|
||||
const LoadableState(
|
||||
isLoading: true,
|
||||
data: null,
|
||||
lastFetch: null,
|
||||
reFetch: null,
|
||||
error: null,
|
||||
);
|
||||
|
||||
@override
|
||||
TimetableState fromStorage(Map<String, dynamic> json) => fromNothing();
|
||||
|
||||
@override
|
||||
Map<String, dynamic>? toStorage(TimetableState state) => null;
|
||||
|
||||
@override
|
||||
Future<void> gatherData() async {
|
||||
final initial = innerState ?? fromNothing();
|
||||
|
||||
Object? firstError;
|
||||
void recordError(Object e) {
|
||||
firstError ??= e;
|
||||
}
|
||||
|
||||
await Future.wait([
|
||||
_loadCurrentWeek(initial.startDate, initial.endDate, onError: recordError),
|
||||
_loadStaticReferenceData(onError: recordError),
|
||||
]);
|
||||
|
||||
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();
|
||||
|
||||
Future<void> _loadCurrentWeek(
|
||||
DateTime startDate,
|
||||
DateTime endDate, {
|
||||
void Function(Object)? onError,
|
||||
}) async {
|
||||
final requestStart = DateTime.now();
|
||||
_lastWeekRequestStart = requestStart;
|
||||
try {
|
||||
final week = await repo.data.getElementWeek(
|
||||
type,
|
||||
elementId,
|
||||
startDate,
|
||||
endDate,
|
||||
onError: onError,
|
||||
);
|
||||
if (_lastWeekRequestStart.isAfter(requestStart)) return;
|
||||
_writeWeekToCache(startDate, week);
|
||||
} catch (e) {
|
||||
log('getElementWeek error for $startDate–$endDate: $e');
|
||||
onError?.call(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadStaticReferenceData({
|
||||
void Function(Object)? onError,
|
||||
}) async {
|
||||
try {
|
||||
final (rooms, subjects, schoolHolidays, schoolyear) = await (
|
||||
repo.data.getRooms(onError: onError),
|
||||
repo.data.getSubjects(onError: onError),
|
||||
repo.data.getSchoolHolidays(onError: onError),
|
||||
repo.data.getCurrentSchoolyear(onError: onError),
|
||||
).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();
|
||||
add(
|
||||
Emit(
|
||||
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Timegrid load failure falls back to a hardcoded schedule in the UI.
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.getElementWeek(type, elementId, start, end)
|
||||
.then((week) => _writeWeekToCache(start, week))
|
||||
.catchError((_) {});
|
||||
}
|
||||
|
||||
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
|
||||
final key = _weekKeyFormat.format(weekStart);
|
||||
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);
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart';
|
||||
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
|
||||
import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
|
||||
import '../../../../../api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart';
|
||||
import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
|
||||
import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
|
||||
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||
import '../../timetable/data_provider/timetable_data_provider.dart';
|
||||
|
||||
/// Data access for a foreign element's timetable. The week comes from the
|
||||
/// element-specific endpoint; all reference data (rooms/subjects/holidays/
|
||||
/// school year/timegrid) is school-wide and identical to the user's own plan,
|
||||
/// so it is delegated to the existing [TimetableDataProvider] (which already
|
||||
/// caches it). Custom events are intentionally absent — they are user-private.
|
||||
class ForeignTimetableDataProvider {
|
||||
final TimetableDataProvider _base;
|
||||
|
||||
ForeignTimetableDataProvider([TimetableDataProvider? base])
|
||||
: _base = base ?? TimetableDataProvider();
|
||||
|
||||
Future<TimetableGetWeekResponse> getElementWeek(
|
||||
TimetableElementType type,
|
||||
int id,
|
||||
DateTime startDate,
|
||||
DateTime endDate, {
|
||||
void Function(Object)? onError,
|
||||
}) async {
|
||||
try {
|
||||
return await TimetableGetElementWeek().run(
|
||||
type: type,
|
||||
id: id,
|
||||
from: startDate,
|
||||
until: endDate,
|
||||
);
|
||||
} catch (e) {
|
||||
onError?.call(e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<TimetableGetRoomsResponse> getRooms({
|
||||
void Function(Object)? onError,
|
||||
bool renew = false,
|
||||
}) => _base.getRooms(onError: onError, renew: renew);
|
||||
|
||||
Future<TimetableGetSubjectsResponse> getSubjects({
|
||||
void Function(Object)? onError,
|
||||
bool renew = false,
|
||||
}) => _base.getSubjects(onError: onError, renew: renew);
|
||||
|
||||
Future<TimetableGetHolidaysResponse> getSchoolHolidays({
|
||||
void Function(Object)? onError,
|
||||
bool renew = false,
|
||||
}) => _base.getSchoolHolidays(onError: onError, renew: renew);
|
||||
|
||||
Future<TimetableGetSchoolyearResponse> getCurrentSchoolyear({
|
||||
void Function(Object)? onError,
|
||||
bool renew = false,
|
||||
}) => _base.getCurrentSchoolyear(onError: onError, renew: renew);
|
||||
|
||||
Future<TimetableGetTimegridResponse> getTimegrid({bool renew = false}) =>
|
||||
_base.getTimegrid(renew: renew);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import '../../../infrastructure/repository/repository.dart';
|
||||
import '../../timetable/bloc/timetable_state.dart';
|
||||
import '../data_provider/foreign_timetable_data_provider.dart';
|
||||
|
||||
class ForeignTimetableRepository extends Repository<TimetableState> {
|
||||
final ForeignTimetableDataProvider _provider;
|
||||
|
||||
ForeignTimetableRepository([ForeignTimetableDataProvider? provider])
|
||||
: _provider = provider ?? ForeignTimetableDataProvider();
|
||||
|
||||
ForeignTimetableDataProvider get data => _provider;
|
||||
}
|
||||
Reference in New Issue
Block a user