import 'dart:async'; import 'dart:developer'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../../api/connect/rmv/rmv_models.dart'; import '../../../../../api/errors/error_mapper.dart'; import '../../../../../storage/timetable_settings.dart'; import '../../../../../view/pages/timetable/data/commute_direction.dart'; import '../repository/commute_repository.dart'; /// First/last-lesson timestamps used as commute anchors. class LessonSpan { final DateTime firstStart; final DateTime lastEnd; const LessonSpan(this.firstStart, this.lastEnd); } /// Immutable per-day commute snapshot. class CommuteDayEntry { final List morning; final List evening; final bool loading; final String? errorMessage; final int? loadedAtMs; const CommuteDayEntry({ this.morning = const [], this.evening = const [], this.loading = false, this.errorMessage, this.loadedAtMs, }); CommuteDayEntry copyWith({ List? morning, List? evening, bool? loading, String? errorMessage, int? loadedAtMs, }) => CommuteDayEntry( morning: morning ?? this.morning, evening: evening ?? this.evening, loading: loading ?? this.loading, errorMessage: errorMessage, loadedAtMs: loadedAtMs ?? this.loadedAtMs, ); } /// Holds the daily commute trips for the currently visible timetable week. /// State is an immutable `Map` keyed by the local /// date ("yyyy-MM-dd"). Entries older than [_ttl] are reloaded on the next /// [ensureLoaded] call. class CommuteCubit extends Cubit> { static const Duration _ttl = Duration(minutes: 5); static const int _maxTrips = 3; final CommuteRepository _repo = CommuteRepository(); final Set _inflight = {}; CommuteCubit() : super(const {}); static String keyFor(DateTime day) { String two(int v) => v.toString().padLeft(2, '0'); return '${day.year}-${two(day.month)}-${two(day.day)}'; } List tripsFor(DateTime day, CommuteDirection direction) { final entry = state[keyFor(day)]; if (entry == null) return const []; return switch (direction) { CommuteDirection.toSchool => entry.morning, CommuteDirection.fromSchool => entry.evening, }; } bool isLoading(DateTime day) => state[keyFor(day)]?.loading ?? false; String? errorFor(DateTime day) => state[keyFor(day)]?.errorMessage; /// Triggers RMV trip lookups for every day in [lessonsByDay]. Skips days /// whose cache is still fresh. Safe to call on every rebuild. void ensureLoaded({ required Map lessonsByDay, required TimetableSettings settings, }) { if (!settings.showCommuteInTimetable) return; final home = settings.homeStation; final school = settings.schoolStation; if (home == null || school == null) return; final now = DateTime.now().millisecondsSinceEpoch; final ttlMs = _ttl.inMilliseconds; for (final entry in lessonsByDay.entries) { final day = entry.key; final span = entry.value; final key = keyFor(day); if (_inflight.contains(key)) continue; final existing = state[key]; if (existing != null && existing.loadedAtMs != null && now - existing.loadedAtMs! < ttlMs) { continue; } _inflight.add(key); unawaited( _loadDay( day: day, span: span, home: home, school: school, bufferMinutes: settings.commuteBufferMinutes, ).whenComplete(() => _inflight.remove(key)), ); } } Future _loadDay({ required DateTime day, required LessonSpan span, required StopLocation home, required StopLocation school, required int bufferMinutes, }) async { final key = keyFor(day); _set(key, (e) => e.copyWith(loading: true, errorMessage: null)); final buffer = Duration(minutes: bufferMinutes); final arrivalDeadline = span.firstStart.subtract(buffer); final departureEarliest = span.lastEnd.add(buffer); log( 'commute $key request: home=${home.id}/${home.name} → ' 'school=${school.id}/${school.name} arrive_by=$arrivalDeadline ' 'depart_from=$departureEarliest', ); try { final results = await Future.wait>([ _repo.findTrips( from: home, to: school, when: arrivalDeadline, byArrival: true, max: _maxTrips, ), _repo.findTrips( from: school, to: home, when: departureEarliest, byArrival: false, max: _maxTrips, ), ]); log( 'commute $key result: ${results[0].length} morning, ' '${results[1].length} evening', ); _set( key, (e) => e.copyWith( morning: results[0], evening: results[1], loading: false, loadedAtMs: DateTime.now().millisecondsSinceEpoch, errorMessage: null, ), ); } catch (e, st) { log('commute $key load failed: $e', stackTrace: st); _set( key, (e2) => e2.copyWith( loading: false, errorMessage: errorToUserMessage(e), loadedAtMs: DateTime.now().millisecondsSinceEpoch, ), ); } } void _set(String key, CommuteDayEntry Function(CommuteDayEntry) update) { final next = Map.from(state); next[key] = update(next[key] ?? const CommuteDayEntry()); emit(next); } Future reset() async { _inflight.clear(); emit(const {}); } }