Files
Client/lib/widget_data/widget_publisher.dart
T

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';
}
}
}