simplified code across the app and consolidated duplicated helpers

This commit is contained in:
2026-09-27 17:29:54 +02:00
parent 3e856d0b1c
commit ee1240f71a
155 changed files with 1772 additions and 2546 deletions
@@ -40,7 +40,7 @@ class LoadableStateConsumer<
super.key,
});
static Duration animationDuration = const Duration(milliseconds: 200);
static const Duration animationDuration = Duration(milliseconds: 200);
@override
Widget build(BuildContext context) {
@@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../widget/info_dialog.dart';
import '../bloc/loadable_state_bloc.dart';
import 'loadable_state_consumer.dart';
class LoadableStateErrorBar extends StatelessWidget {
final bool visible;
@@ -21,8 +22,6 @@ class LoadableStateErrorBar extends StatelessWidget {
super.key,
});
final Duration animationDuration = const Duration(milliseconds: 200);
@override
Widget build(BuildContext context) {
final bloc = context.watch<LoadableStateBloc>();
@@ -31,9 +30,9 @@ class LoadableStateErrorBar extends StatelessWidget {
final shouldShow = visible || isOfflineWithCache;
return AnimatedSize(
duration: animationDuration,
duration: LoadableStateConsumer.animationDuration,
child: AnimatedSwitcher(
duration: animationDuration,
duration: LoadableStateConsumer.animationDuration,
transitionBuilder: (Widget child, Animation<double> animation) =>
SlideTransition(
position: Tween<Offset>(
@@ -46,35 +45,27 @@ class LoadableStateErrorBar extends StatelessWidget {
key: Key(shouldShow.hashCode.toString()),
visible: shouldShow,
replacement: const SizedBox(width: double.infinity),
child: Builder(
builder: (context) {
var bloc = context.watch<LoadableStateBloc>();
return InkWell(
onTap: () {
if (!bloc.isConnected()) return;
final body = [
if (message != null && message!.isNotEmpty) message!,
if (technicalDetails != null &&
technicalDetails!.isNotEmpty)
technicalDetails!,
].join('\n\n');
if (body.isEmpty) return;
InfoDialog.show(
context,
body,
copyable: true,
title: 'Fehlerdetails',
);
},
child: Container(
height: 20,
decoration: BoxDecoration(
color: bloc.connectionColor(context),
),
child: LoadableStateErrorBarText(lastUpdated: lastUpdated),
),
child: InkWell(
onTap: () {
if (!bloc.isConnected()) return;
final body = [
if (message != null && message!.isNotEmpty) message!,
if (technicalDetails != null && technicalDetails!.isNotEmpty)
technicalDetails!,
].join('\n\n');
if (body.isEmpty) return;
InfoDialog.show(
context,
body,
copyable: true,
title: 'Fehlerdetails',
);
},
child: Container(
height: 20,
decoration: BoxDecoration(color: bloc.connectionColor(context)),
child: LoadableStateErrorBarText(lastUpdated: lastUpdated),
),
),
),
),
@@ -106,8 +97,9 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
@override
Widget build(BuildContext context) {
var bloc = context.watch<LoadableStateBloc>();
final bloc = context.watch<LoadableStateBloc>();
final foreground = bloc.connectionForegroundColor(context);
final text = bloc.connectionText(lastUpdated: widget.lastUpdated);
// liveRegion, damit das Auftauchen des Offline-/Fehlerbanners angesagt wird;
// Row-Semantik ausgeschlossen (Icon + Text) und Text über das explizite
@@ -115,17 +107,14 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
return Semantics(
liveRegion: true,
container: true,
label: bloc.connectionText(lastUpdated: widget.lastUpdated),
label: text,
child: ExcludeSemantics(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(bloc.connectionIcon(), size: 14, color: foreground),
const SizedBox(width: 10),
Text(
bloc.connectionText(lastUpdated: widget.lastUpdated),
style: TextStyle(fontSize: 12, color: foreground),
),
Text(text, style: TextStyle(fontSize: 12, color: foreground)),
],
),
),
@@ -253,7 +253,6 @@ abstract class LoadableHydratedBloc<
/// `==`, so use fields that compare cheaply, e.g. by identity).
Object? persistenceKey(TState data) => data;
Future<void> gatherData();
TRepository repository();
+13 -25
View File
@@ -177,9 +177,7 @@ class AppModule {
for (final missing in Modules.values) {
if (!seen.add(missing)) continue;
var insertAt = 0;
for (final predecessor in Modules.values.takeWhile(
(m) => m != missing,
)) {
for (final predecessor in Modules.values.takeWhile((m) => m != missing)) {
final pos = order.indexOf(predecessor);
if (pos >= insertAt) insertAt = pos + 1;
}
@@ -231,33 +229,26 @@ class AppModule {
static const int minBottomBarSlots = 3;
static const int maxBottomBarSlots = 5;
static int resolveBottomBarSlotCount(BuildContext context) {
static int _slotCount(BuildContext context, int available) {
final settings = context.read<SettingsCubit>().val().modulesSettings;
final available = modules(context).length;
int desired;
if (settings.autoFillBottomBar) {
// 4 content tabs so the bar shows 5 entries including "Mehr" — enough
// for the Ticker module without pushing "Dateien" into the overflow.
desired = 4;
} else {
desired = settings.fixedBottomBarSlots;
}
desired = desired.clamp(minBottomBarSlots, maxBottomBarSlots);
return desired.clamp(0, available);
// 4 content tabs so the bar shows 5 entries including "Mehr" — enough
// for the Ticker module without pushing "Dateien" into the overflow.
final desired = settings.autoFillBottomBar
? 4
: settings.fixedBottomBarSlots;
return desired
.clamp(minBottomBarSlots, maxBottomBarSlots)
.clamp(0, available);
}
static List<AppModule> getBottomBarModules(BuildContext context) {
final all = modules(context).values.toList();
final slots = resolveBottomBarSlotCount(context);
return all.take(slots).toList();
return all.take(_slotCount(context, all.length)).toList();
}
static List<AppModule> getOverhangModules(BuildContext context) {
final all = modules(context).values.toList();
final slots = resolveBottomBarSlotCount(context);
return all.skip(slots).toList();
return all.skip(_slotCount(context, all.length)).toList();
}
Widget toListTile(
@@ -290,10 +281,7 @@ class AppModule {
: const Icon(Icons.arrow_right),
);
PersistentTabConfig toBottomTab(
BuildContext context, {
Widget Function(IconData icon)? iconBuilder,
}) => PersistentTabConfig(
PersistentTabConfig toBottomTab(BuildContext context) => PersistentTabConfig(
screen: Breaker(breaker: breakerArea, child: create()),
item: ItemConfig(
activeForegroundColor: Theme.of(context).primaryColor,
@@ -12,9 +12,9 @@ class BreakerDataProvider {
bool renew = true,
void Function(Object)? onError,
}) => resolveFromCache<GetBreakersResponse>(
(onUpdate, onError) =>
GetBreakersCache(renew: renew, onUpdate: onUpdate, onError: onError),
onError: onError,
operationName: 'getBreakers',
);
(onUpdate, onError) =>
GetBreakersCache(renew: renew, onUpdate: onUpdate, onError: onError),
onError: onError,
operationName: 'getBreakers',
);
}
+33 -30
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:developer';
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
@@ -10,6 +9,7 @@ import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
import '../../../../../utils/exponential_backoff.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 '../../chat_list/bloc/chat_list_bloc.dart';
@@ -26,7 +26,6 @@ class ChatBloc
// A loop parked in its request survives stop+start for the same token
// (pause/resume); the generation retires it so only one loop polls.
int _pollGeneration = 0;
int _backoffMs = 0;
int _lastKnownMessageId = 0;
bool _appResumed = true;
@@ -68,8 +67,7 @@ class ChatBloc
@override
Map<String, dynamic>? toStorage(ChatState state) {
final response = state.chatResponse;
if (response == null ||
response.data.length <= _kMaxPersistedMessages) {
if (response == null || response.data.length <= _kMaxPersistedMessages) {
return state.toJson();
}
// Keep only the newest N; trimming drops older messages, so there is
@@ -129,9 +127,6 @@ class ChatBloc
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
}
/// No-op when the bloc has already moved on to a different token: when
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
/// before B's dispose fires.
/// The chat view may still be popping when the sign-out resets this bloc,
/// so leaveChat would find no token and the long-poll would keep running.
@override
@@ -141,6 +136,9 @@ class ChatBloc
return super.reset();
}
/// No-op when the bloc has already moved on to a different token: when
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
/// before B's dispose fires.
void leaveChat(String fromToken) {
if ((innerState?.currentToken ?? '') != fromToken) return;
_chatViewActive = false;
@@ -272,7 +270,6 @@ class ChatBloc
if (_pollingToken == token) return;
_stopLongPoll();
_pollingToken = token;
_backoffMs = 0;
_lastKnownMessageId = _maxMessageId(innerState?.chatResponse);
unawaited(_pollLoop(token, ++_pollGeneration));
}
@@ -280,11 +277,11 @@ class ChatBloc
void _stopLongPoll() {
_pollingToken = null;
_pollGeneration++;
_backoffMs = 0;
}
Future<void> _pollLoop(String token, int generation) async {
bool active() => generation == _pollGeneration && !isClosed;
var failures = 0;
while (active()) {
try {
final response = await LongPollChat(
@@ -293,7 +290,7 @@ class ChatBloc
).run();
if (!active()) return;
_backoffMs = 0;
failures = 0;
if (response == null) continue;
@@ -318,8 +315,13 @@ class ChatBloc
} on Object catch (e) {
if (!active()) return;
log('LongPoll error for $token: $e');
_backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000);
await Future.delayed(Duration(milliseconds: _backoffMs));
await Future<void>.delayed(
exponentialBackoff(
++failures,
base: const Duration(seconds: 2),
max: const Duration(seconds: 30),
),
);
}
}
}
@@ -339,14 +341,7 @@ class ChatBloc
);
return;
}
final byId = <int, GetChatResponseObject>{};
for (final m in current.data) {
byId[m.id] = m;
}
for (final m in incoming.data) {
byId[m.id] = m;
}
final merged = GetChatResponse(byId.values.toSet())
final merged = _mergeById(current, incoming, incomingWins: true)
..headers = incoming.headers;
add(DataGathered((s) => s.copyWith(chatResponse: merged)));
}
@@ -357,14 +352,7 @@ class ChatBloc
void _applyOlderResponse(GetChatResponse older) {
final current = innerState?.chatResponse;
if (current == null) return;
final byId = <int, GetChatResponseObject>{};
for (final m in current.data) {
byId[m.id] = m;
}
for (final m in older.data) {
byId.putIfAbsent(m.id, () => m);
}
final merged = GetChatResponse(byId.values.toSet())
final merged = _mergeById(current, older, incomingWins: false)
..headers = current.headers;
add(
DataGathered(
@@ -377,6 +365,22 @@ class ChatBloc
);
}
GetChatResponse _mergeById(
GetChatResponse current,
GetChatResponse incoming, {
required bool incomingWins,
}) {
final byId = {for (final m in current.data) m.id: m};
for (final m in incoming.data) {
if (incomingWins) {
byId[m.id] = m;
} else {
byId.putIfAbsent(m.id, () => m);
}
}
return GetChatResponse(byId.values.toSet());
}
int _maxMessageId(GetChatResponse? response) {
if (response == null) return 0;
var max = 0;
@@ -386,8 +390,7 @@ class ChatBloc
return max;
}
int _minMessageId(GetChatResponse? response) {
if (response == null) return 0;
int _minMessageId(GetChatResponse response) {
var min = 0;
for (final m in response.data) {
if (m.id <= 0) continue; // skip dummies
@@ -28,18 +28,16 @@ class FilesBloc
@override
Map<String, dynamic>? toStorage(FilesState state) => null;
List<String> get _currentPath => innerState?.currentPath ?? initialPath;
@override
Future<void> gatherData() async {
final path = innerState?.currentPath ?? initialPath;
await _query(path);
}
Future<void> gatherData() => _query(_currentPath);
Future<void> refresh() async {
add(RefetchStarted<FilesState>());
final path = innerState?.currentPath ?? initialPath;
// Explicit user action — bypass the cache TTL so the root listing also
// refetches even though it is otherwise cached for a day.
await _query(path, renew: true);
await _query(_currentPath, renew: true);
}
/// Pull-to-refresh and error-screen retry route through here. Unlike the
@@ -57,13 +55,15 @@ class FilesBloc
}
Future<void> createFolder(String name) async {
final path = innerState?.currentPath ?? initialPath;
await repo.data.createFolder('${path.join('/')}/$name');
await repo.data.createFolder('${_currentPath.join('/')}/$name');
await refresh();
}
Future<void> _query(List<String> path, {bool renew = false}) async {
final pathString = path.isEmpty ? '/' : path.join('/');
// The listing contains the folder itself; only its children are shown.
void dropSelf(ListFilesResponse listing) => listing.files.removeWhere(
(file) => file.name.isEmpty || file.name == path.lastOrNull,
);
// Drop late results when [setPath] has navigated elsewhere or when the
// bloc has been disposed (e.g. share-flow picker closed mid-fetch). Both
@@ -80,16 +80,14 @@ class FilesBloc
ListFilesResponse? listing;
try {
listing = await repo.data.listFiles(
pathString,
path.isEmpty ? '/' : path.join('/'),
renew: renew,
onCacheData: (cached) {
if (isStale()) return;
// Cached payload arrives before the network call settles. Surface it
// immediately via Emit so the listing is visible while isLoading
// stays true and the top loading bar keeps spinning.
cached.files.removeWhere(
(file) => file.name.isEmpty || file.name == path.lastOrNull,
);
dropSelf(cached);
add(Emit((s) => s.copyWith(listing: cached)));
},
onRetry: (next, max) {
@@ -105,9 +103,7 @@ class FilesBloc
if (isStale()) return;
if (listing != null) {
listing.files.removeWhere(
(file) => file.name.isEmpty || file.name == path.lastOrNull,
);
dropSelf(listing);
add(DataGathered((s) => s.copyWith(listing: listing)));
}
if (capturedError != null) addLoadingError(capturedError!);
@@ -3,10 +3,5 @@ import '../bloc/files_state.dart';
import '../data_provider/files_data_provider.dart';
class FilesRepository extends Repository<FilesState> {
final FilesDataProvider _provider;
FilesRepository([FilesDataProvider? provider])
: _provider = provider ?? FilesDataProvider();
FilesDataProvider get data => _provider;
final FilesDataProvider data = FilesDataProvider();
}
@@ -1,16 +1,14 @@
import 'dart:developer';
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 '../../timetable/bloc/timetable_week_loader.dart';
import '../../timetable/data_provider/timetable_data_provider.dart';
import '../repository/foreign_timetable_repository.dart';
/// Drives a foreign element's timetable. Mirrors `TimetableBloc`'s week-loading
/// Drives a foreign element's timetable. Shares `TimetableBloc`'s week loading
/// and navigation but loads weeks from the element endpoint, carries no custom
/// events, and does not persist (page-scoped, recreated per element). Reuses
/// [TimetableState] verbatim so the render pipeline is unchanged; `customEvents`
@@ -21,34 +19,25 @@ class ForeignTimetableBloc
TimetableEvent,
TimetableState,
ForeignTimetableRepository
> {
>
with TimetableWeekLoader<ForeignTimetableRepository> {
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,
});
ForeignTimetableBloc({required this.type, required this.elementId});
@override
ForeignTimetableRepository repository() => ForeignTimetableRepository();
@override
TimetableState fromNothing() {
final reference = DateTime.now().addDays(2);
return TimetableState(
startDate: reference.mondayOfWeek,
endDate: _endOfWeek(reference),
);
}
TimetableDataProvider get referenceData => repo.data.referenceData;
@override
Future<TimetableGetWeekResponse> fetchWeek(DateTime start, DateTime end) =>
repo.data.getElementWeek(type, elementId, start, end);
// Persistence disabled: page-scoped and element-specific, nothing worth
// restoring. toJson returns null so nothing is written; fromJson starts fresh.
@@ -72,116 +61,5 @@ class ForeignTimetableBloc
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(reference.mondayOfWeek, _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.withReferenceData(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
),
),
);
} catch (e) {
onError?.call(e);
}
try {
final timegrid = await repo.data.getTimegrid();
add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
} 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) {
add(Emit((s) => s.withFetchedWeek(weekStart, week)));
}
static DateTime _endOfWeek(DateTime reference) {
final friday = reference.addDays(
DateTime.daysPerWeek - reference.weekday - 2,
);
return DateTime(friday.year, friday.month, friday.day);
}
Future<void> gatherData() => gatherWeek();
}
@@ -1,64 +1,27 @@
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, so it delegates to the existing
/// [TimetableDataProvider] (which caches it). Custom events are intentionally
/// absent — they are user-private.
/// school year/timegrid) is school-wide and comes from [referenceData].
/// Custom events are intentionally absent — they are user-private.
class ForeignTimetableDataProvider {
final TimetableDataProvider _base;
final TimetableDataProvider referenceData;
ForeignTimetableDataProvider([TimetableDataProvider? base])
: _base = base ?? TimetableDataProvider();
ForeignTimetableDataProvider([TimetableDataProvider? referenceData])
: referenceData = referenceData ?? 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);
DateTime endDate,
) async => TimetableGetElementWeek().run(
type: type,
id: id,
from: startDate,
until: endDate,
);
}
@@ -9,13 +9,13 @@ class HolidaysBloc
extends
LoadableHydratedBloc<HolidaysEvent, HolidaysState, HolidaysRepository> {
HolidaysBloc() {
on<SetPastHolidaysVisible>((event, emit) {
add(
on<SetPastHolidaysVisible>(
(event, emit) => add(
Emit(
(state) => state.copyWith(showPastHolidays: event.shouldBeVisible),
),
);
});
),
);
on<DisclaimerDismissed>(
(event, emit) =>
@@ -25,15 +25,13 @@ class HolidaysBloc
bool showPastHolidays() => innerState?.showPastHolidays ?? false;
bool showDisclaimerOnEntry() => innerState?.showDisclaimer ?? false;
List<McHoliday>? getHolidays() =>
innerState?.holidays
.where(
(element) =>
showPastHolidays() ||
element.endDate.isAfter(DateTime.now()),
)
.toList() ??
[];
List<McHoliday> getHolidays() {
final now = DateTime.now();
return innerState?.holidays
.where((h) => showPastHolidays() || h.endDate.isAfter(now))
.toList() ??
[];
}
@override
HolidaysState fromNothing() => const HolidaysState(
@@ -12,20 +12,22 @@ class MarianumDatesBloc
MarianumDatesRepository
> {
MarianumDatesBloc() {
on<SetPastEventsVisible>((event, emit) {
add(
on<SetPastEventsVisible>(
(event, emit) => add(
Emit((state) => state.copyWith(showPastEvents: event.shouldBeVisible)),
);
});
),
);
}
bool showPastEvents() => innerState?.showPastEvents ?? false;
List<MarianumDate>? getEvents() =>
innerState?.events
.where((e) => showPastEvents() || e.end.isAfter(DateTime.now()))
.toList() ??
[];
List<MarianumDate> getEvents() {
final now = DateTime.now();
return innerState?.events
.where((e) => showPastEvents() || e.end.isAfter(now))
.toList() ??
[];
}
@override
MarianumDatesState fromNothing() =>
@@ -3,5 +3,3 @@ import 'marianum_message_state.dart';
sealed class MarianumMessageEvent
extends LoadableHydratedBlocEvent<MarianumMessageState> {}
class MessageEvent extends MarianumMessageEvent {}
@@ -37,5 +37,3 @@ abstract class MarianumMessage with _$MarianumMessage {
factory MarianumMessage.fromJson(Map<String, dynamic> json) =>
_$MarianumMessageFromJson(json);
}
enum GradeAveragesGradingSystem { highSchool, middleSchool }
@@ -12,7 +12,8 @@ import '../bloc/marianum_message_state.dart';
class MarianumMessageGetMessages {
final Dio _dio;
MarianumMessageGetMessages({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
MarianumMessageGetMessages({Dio? dio})
: _dio = dio ?? MarianumConnectApi.dio();
Future<MarianumMessageList> run() async {
try {
@@ -2,5 +2,3 @@ import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_
import 'ticker_state.dart';
sealed class TickerEvent extends LoadableHydratedBlocEvent<TickerState> {}
class TickerLoadEvent extends TickerEvent {}
@@ -1,6 +1,5 @@
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import 'ticker_page_state.dart';
sealed class TickerPageEvent extends LoadableHydratedBlocEvent<TickerPageState> {}
class TickerPageLoadEvent extends TickerPageEvent {}
sealed class TickerPageEvent
extends LoadableHydratedBlocEvent<TickerPageState> {}
@@ -1,14 +1,13 @@
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/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../data_provider/timetable_data_provider.dart';
import '../repository/timetable_repository.dart';
import 'timetable_event.dart';
import 'timetable_state.dart';
import 'timetable_week_loader.dart';
class TimetableBloc
extends
@@ -16,11 +15,10 @@ class TimetableBloc
TimetableEvent,
TimetableState,
TimetableRepository
> {
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].
>
with TimetableWeekLoader<TimetableRepository> {
/// Set by [retry] to force the next [gatherData] to bypass the custom-event
/// cache and actually hit the network. Cleared at the top of [gatherData].
bool _forceRenew = false;
@override
@@ -33,24 +31,21 @@ class TimetableBloc
TimetableRepository repository() => TimetableRepository();
@override
TimetableState fromNothing() {
final reference = DateTime.now().addDays(2);
return TimetableState(
startDate: reference.mondayOfWeek,
endDate: _endOfWeek(reference),
);
}
TimetableDataProvider get referenceData => repo.data;
@override
Future<TimetableGetWeekResponse> fetchWeek(DateTime start, DateTime end) =>
repo.data.getWeek(start, end);
/// 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: reference.mondayOfWeek,
endDate: _endOfWeek(reference),
final (start, end) = currentTimetableWeek();
return TimetableState.fromJson(json).copyWith(
startDate: start,
endDate: end,
accessibleStartDate: null,
accessibleEndDate: null,
);
@@ -60,50 +55,14 @@ class TimetableBloc
Map<String, dynamic>? toStorage(TimetableState state) => state.toJson();
@override
Future<void> gatherData() async {
final initial = innerState ?? fromNothing();
Future<void> gatherData() {
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);
return gatherWeek(
also: (onError) => _loadCustomEvents(onError: onError, renew: renew),
);
}
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)));
runInSession(() {
_loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
});
}
void resetWeek() {
final reference = DateTime.now().addDays(2);
changeWeek(reference.mondayOfWeek, _endOfWeek(reference));
}
void refresh() => fetch();
Future<void> addCustomEvent(CustomTimetableEvent event) async {
await repo.data.addCustomEvent(event);
await _refreshCustomEvents();
@@ -132,67 +91,10 @@ class TimetableBloc
}
Future<void> _refreshSubjects() async {
final subjects = await repo.data.getSubjects(renew: true);
final subjects = await repo.data.getSubjects();
add(DataGathered((s) => s.withReferenceData(subjects: subjects)));
}
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);
} 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.withReferenceData(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
),
),
);
} catch (e) {
onError?.call(e);
}
try {
final timegrid = await repo.data.getTimegrid(renew: renew);
add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
} catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI layer.
}
}
Future<void> _loadCustomEvents({
void Function(Object)? onError,
bool renew = false,
@@ -213,27 +115,4 @@ class TimetableBloc
final events = await repo.data.getCustomEvents(renew: true);
add(DataGathered((s) => s.withReferenceData(customEvents: events)));
}
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(start, end)
.then((week) => _writeWeekToCache(start, week))
.catchError((_) {});
}
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
add(Emit((s) => s.withFetchedWeek(weekStart, week)));
}
static DateTime _endOfWeek(DateTime reference) {
final friday = reference.addDays(
DateTime.daysPerWeek - reference.weekday - 2,
);
return DateTime(friday.year, friday.month, friday.day);
}
}
@@ -98,9 +98,9 @@ abstract class TimetableState with _$TimetableState {
);
}
bool get hasReferenceData =>
rooms != null &&
subjects != null &&
schoolHolidays != null &&
customEvents != null;
/// Everything a foreign plan needs to render; it never has custom events.
bool get hasSchoolReferenceData =>
rooms != null && subjects != null && schoolHolidays != null;
bool get hasReferenceData => hasSchoolReferenceData && customEvents != null;
}
@@ -0,0 +1,138 @@
import 'dart:developer';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../extensions/date_time.dart';
import '../../../infrastructure/repository/repository.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 '../data_provider/timetable_data_provider.dart';
import 'timetable_event.dart';
import 'timetable_state.dart';
/// The date whose week the timetable opens on — two days ahead, so Saturday
/// and Sunday already show the next week.
DateTime timetableAnchorDate() => DateTime.now().addDays(2);
/// Monday 00:00 to Friday 00:00 of the week the timetable opens on.
(DateTime, DateTime) currentTimetableWeek() {
final reference = timetableAnchorDate();
final friday = reference.addDays(
DateTime.daysPerWeek - reference.weekday - 2,
);
return (reference.mondayOfWeek, friday.dateOnly);
}
/// Week navigation, week loading/prefetching and the school-wide reference
/// data, shared by the own and the foreign timetable.
mixin TimetableWeekLoader<R extends Repository<TimetableState>>
on LoadableHydratedBloc<TimetableEvent, TimetableState, R> {
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
Future<TimetableGetWeekResponse> fetchWeek(DateTime start, DateTime end);
TimetableDataProvider get referenceData;
@override
TimetableState fromNothing() {
final (start, end) = currentTimetableWeek();
return TimetableState(startDate: start, endDate: end);
}
/// Loads the viewed week and the reference data (plus [also]), fails with
/// the first error of any of them and prefetches the neighbouring weeks.
Future<void> gatherWeek({
Future<void> Function(void Function(Object) onError)? also,
}) async {
final initial = innerState ?? fromNothing();
Object? firstError;
void recordError(Object e) => firstError ??= e;
await Future.wait([
_loadWeek(initial.startDate, initial.endDate, onError: recordError),
_loadReferenceData(onError: recordError),
if (also != null) also(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)));
runInSession(() {
_loadWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
});
}
void refresh() => fetch();
Future<void> _loadWeek(
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
}) async {
final requestStart = DateTime.now();
_lastWeekRequestStart = requestStart;
try {
final week = await fetchWeek(startDate, endDate);
if (_lastWeekRequestStart.isAfter(requestStart)) return;
_writeWeekToCache(startDate, week);
} catch (e) {
log('Week load error for $startDate–$endDate: $e');
onError?.call(e);
}
}
Future<void> _loadReferenceData({void Function(Object)? onError}) async {
try {
final (rooms, subjects, schoolHolidays, schoolyear) = await (
referenceData.getRooms(onError: onError),
referenceData.getSubjects(onError: onError),
referenceData.getSchoolHolidays(onError: onError),
referenceData.getCurrentSchoolyear(onError: onError),
).wait;
add(
Emit(
(s) => s.withReferenceData(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
),
),
);
} catch (e) {
onError?.call(e);
}
try {
final timegrid = await referenceData.getTimegrid();
add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
} 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) {
fetchWeek(
start,
end,
).then((week) => _writeWeekToCache(start, week)).catchError((_) {});
}
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
add(Emit((s) => s.withFetchedWeek(weekStart, week)));
}
}
@@ -23,72 +23,31 @@ import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_
import '../../../../../api/request_cache.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.
/// its own HTTP call; this provider exposes the futures so the bloc can chain
/// them without seeing the dio layer.
class TimetableDataProvider {
Future<TimetableGetWeekResponse> getWeek(
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
bool renew = false,
}) async {
try {
return await TimetableGetWeek().run(from: startDate, until: endDate);
} catch (e) {
onError?.call(e);
rethrow;
}
}
DateTime endDate,
) async => TimetableGetWeek().run(from: startDate, until: endDate);
Future<TimetableGetRoomsResponse> getRooms({
void Function(Object)? onError,
bool renew = false,
}) async {
try {
return await TimetableGetRooms().run();
} catch (e) {
onError?.call(e);
rethrow;
}
}
}) => _reportingErrors(TimetableGetRooms().run, onError);
Future<TimetableGetSubjectsResponse> getSubjects({
void Function(Object)? onError,
bool renew = false,
}) async {
try {
return await TimetableGetSubjects().run();
} catch (e) {
onError?.call(e);
rethrow;
}
}
}) => _reportingErrors(TimetableGetSubjects().run, onError);
Future<TimetableGetHolidaysResponse> getSchoolHolidays({
void Function(Object)? onError,
bool renew = false,
}) async {
try {
return await TimetableGetHolidays().run();
} catch (e) {
onError?.call(e);
rethrow;
}
}
}) => _reportingErrors(TimetableGetHolidays().run, onError);
Future<TimetableGetSchoolyearResponse> getCurrentSchoolyear({
void Function(Object)? onError,
bool renew = false,
}) async {
try {
return await TimetableGetSchoolyear().run();
} catch (e) {
onError?.call(e);
rethrow;
}
}
}) => _reportingErrors(TimetableGetSchoolyear().run, onError);
Future<TimetableGetTimegridResponse> getTimegrid({bool renew = false}) =>
Future<TimetableGetTimegridResponse> getTimegrid() =>
TimetableGetTimegrid().run();
Future<GetCustomTimetableEventResponse> getCustomEvents({
@@ -131,4 +90,18 @@ class TimetableDataProvider {
if (DemoMode.active) return Future.value();
return TimetableSubjectColorRemove().run(subjectShort);
}
// Reports the original error before rethrowing: callers await several of
// these together, and a parallel wait only surfaces a combined error.
static Future<T> _reportingErrors<T>(
Future<T> Function() request,
void Function(Object)? onError,
) async {
try {
return await request();
} catch (e) {
onError?.call(e);
rethrow;
}
}
}