implemented RMV commute integration in the timetable, added Nominatim geocoding for home station lookup, created CommuteCubit for daily trip management with TTL caching, and introduced specialized timetable tiles, detail sheets, and settings for transit connections and walking buffers

This commit is contained in:
2026-05-20 22:50:57 +02:00
parent 067012cc84
commit 46d6b3410e
20 changed files with 1513 additions and 20 deletions
@@ -0,0 +1,191 @@
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 {});
}
}