simplified code across the app and consolidated duplicated helpers
This commit is contained in:
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
-1
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+12
-49
@@ -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 }
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user