Files
Client/lib/state/app/modules/commute/bloc/commute_cubit.dart
T

192 lines
5.5 KiB
Dart

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<Trip> morning;
final List<Trip> 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<Trip>? morning,
List<Trip>? 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<DateKey, CommuteDayEntry>` keyed by the local
/// date ("yyyy-MM-dd"). Entries older than [_ttl] are reloaded on the next
/// [ensureLoaded] call.
class CommuteCubit extends Cubit<Map<String, CommuteDayEntry>> {
static const Duration _ttl = Duration(minutes: 5);
static const int _maxTrips = 3;
final CommuteRepository _repo = CommuteRepository();
final Set<String> _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<Trip> 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<DateTime, LessonSpan> 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<void> _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<List<Trip>>([
_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<String, CommuteDayEntry>.from(state);
next[key] = update(next[key] ?? const CommuteDayEntry());
emit(next);
}
Future<void> reset() async {
_inflight.clear();
emit(const {});
}
}