150 lines
5.2 KiB
Dart
150 lines
5.2 KiB
Dart
import 'dart:convert';
|
|
import 'dart:developer';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../model/account_data.dart';
|
|
import '../state/app/modules/timetable/bloc/timetable_state.dart';
|
|
import '../storage/settings.dart';
|
|
import 'widget_data_mapper.dart';
|
|
import 'widget_sync.dart';
|
|
|
|
/// Pushes timetable state to the native widget whenever the foreground bloc
|
|
/// has fresh data, so the widget doesn't have to wait for the next periodic
|
|
/// background fetch.
|
|
class WidgetPublisher {
|
|
/// Debug-only "now" offset. Gated by [kDebugMode] so a stray non-zero
|
|
/// value cannot ship in release.
|
|
static const Duration debugTimeShift = Duration.zero;
|
|
|
|
static DateTime widgetNow() =>
|
|
kDebugMode ? DateTime.now().add(debugTimeShift) : DateTime.now();
|
|
|
|
/// Identical snapshots are still re-written after this long, so the stored
|
|
/// `fetchedAt` (freshness for the background refresh) doesn't go stale.
|
|
static const Duration _republishAfter = Duration(minutes: 10);
|
|
|
|
static Future<void> _queue = Future.value();
|
|
static (bool, String, bool)? _lastFlags;
|
|
static String? _lastSignature;
|
|
static DateTime? _lastPublishedAt;
|
|
|
|
/// Forgets what was written last, so the next publish writes again. Called
|
|
/// whenever the stored widget data is cleared (sign-out).
|
|
static void resetDedupe() {
|
|
_lastSignature = null;
|
|
_lastPublishedAt = null;
|
|
}
|
|
|
|
/// Publishes run one at a time: they are fire-and-forget from the bloc
|
|
/// stream, and interleaved writes could leave day and week data from
|
|
/// different states behind.
|
|
/// [epoch] is the session the [state] belongs to; defaults to the current
|
|
/// one. Callers that delay the publish must capture it up front.
|
|
static Future<void> publishFromBlocState(
|
|
TimetableState state, {
|
|
Settings? settings,
|
|
bool isTeacher = false,
|
|
int? epoch,
|
|
}) {
|
|
final sessionEpoch = epoch ?? AccountData().sessionEpoch;
|
|
return _queue = _queue.then(
|
|
(_) => _publish(
|
|
state,
|
|
settings: settings,
|
|
isTeacher: isTeacher,
|
|
epoch: sessionEpoch,
|
|
),
|
|
);
|
|
}
|
|
|
|
static Future<void> _publish(
|
|
TimetableState state, {
|
|
required Settings? settings,
|
|
required bool isTeacher,
|
|
required int epoch,
|
|
}) async {
|
|
if (!AccountData().isCurrentSession(epoch)) return;
|
|
try {
|
|
final connectDouble =
|
|
settings?.timetableSettings.connectDoubleLessons ?? true;
|
|
// Mirror into widget storage so the background isolate sees the same
|
|
// values the user just toggled — concurrently, they are independent.
|
|
final flags = (connectDouble, _themeName(settings?.appTheme), isTeacher);
|
|
if (flags != _lastFlags) {
|
|
await Future.wait([
|
|
WidgetSync.setConnectDoubleLessons(flags.$1),
|
|
WidgetSync.setThemeMode(flags.$2),
|
|
WidgetSync.setIsTeacher(flags.$3),
|
|
]);
|
|
_lastFlags = flags;
|
|
}
|
|
final lessons = state.getAllKnownLessons();
|
|
final now = widgetNow();
|
|
final dayData = WidgetDataMapper.buildDayData(
|
|
now: now,
|
|
lessons: lessons,
|
|
subjects: state.subjects,
|
|
rooms: state.rooms,
|
|
holidays: state.schoolHolidays,
|
|
timegrid: state.timegrid,
|
|
customEvents: state.customEvents,
|
|
connectDoubleLessons: connectDouble,
|
|
showClassInsteadOfTeacher: isTeacher,
|
|
);
|
|
final weekData = WidgetDataMapper.buildWeekData(
|
|
now: now,
|
|
lessons: lessons,
|
|
subjects: state.subjects,
|
|
rooms: state.rooms,
|
|
holidays: state.schoolHolidays,
|
|
timegrid: state.timegrid,
|
|
customEvents: state.customEvents,
|
|
connectDoubleLessons: connectDouble,
|
|
showClassInsteadOfTeacher: isTeacher,
|
|
);
|
|
// A publish still running at sign-out would put the previous account's
|
|
// plan back onto the just cleared widget.
|
|
if (!AccountData().isCurrentSession(epoch)) return;
|
|
// Most bloc emits (week swipes, prefetches) don't touch the widget's
|
|
// window; skip the SharedPreferences commits and widget re-render then.
|
|
final signature = jsonEncode([
|
|
_withoutFetchedAt(dayData.toJson()),
|
|
_withoutFetchedAt(weekData.toJson()),
|
|
]);
|
|
final lastAt = _lastPublishedAt;
|
|
if (signature == _lastSignature &&
|
|
lastAt != null &&
|
|
now.difference(lastAt) < _republishAfter) {
|
|
return;
|
|
}
|
|
_lastSignature = signature;
|
|
_lastPublishedAt = now;
|
|
await WidgetSync.writeDayData(dayData);
|
|
await WidgetSync.writeWeekData(weekData);
|
|
await WidgetSync.setLoggedIn(true);
|
|
await WidgetSync.triggerUpdate();
|
|
} on Object catch (e, s) {
|
|
// Catch Object: non-Exception Errors (RangeError, StateError) from the
|
|
// bloc layer must not escape into the stream listener.
|
|
log('WidgetPublisher.publishFromBlocState failed: $e', stackTrace: s);
|
|
}
|
|
}
|
|
|
|
static Map<String, Object?> _withoutFetchedAt(Map<String, Object?> json) =>
|
|
Map.of(json)..remove('fetchedAt');
|
|
|
|
static String _themeName(ThemeMode? mode) {
|
|
switch (mode) {
|
|
case ThemeMode.light:
|
|
return 'light';
|
|
case ThemeMode.dark:
|
|
return 'dark';
|
|
case ThemeMode.system:
|
|
case null:
|
|
return 'system';
|
|
}
|
|
}
|
|
}
|