192 lines
5.5 KiB
Dart
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 {});
|
|
}
|
|
}
|