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 _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 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 _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 _withoutFetchedAt(Map 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'; } } }