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
+82 -2
View File
@@ -4,12 +4,14 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/modules/commute/bloc/commute_cubit.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../storage/timetable_settings.dart';
import 'custom_events/custom_event_edit_dialog.dart';
import 'data/arbitrary_appointment.dart';
import 'data/commute_appointment_factory.dart';
import 'data/lesson_period_schedule.dart';
import 'data/timetable_appointment_factory.dart';
import 'data/webuntis_time.dart';
@@ -33,6 +35,7 @@ class _TimetableState extends State<Timetable> {
List<Appointment>? _cachedAppointments;
int? _lastDataVersion;
TimetableSettings? _lastTimetableSettings;
Map<String, CommuteDayEntry>? _lastCommuteState;
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
@@ -58,15 +61,23 @@ class _TimetableState extends State<Timetable> {
.watch<SettingsCubit>()
.val()
.timetableSettings;
final commuteState = context.watch<CommuteCubit>().state;
// Kick off any missing commute fetches for the currently visible weeks.
// The cubit's ttl/inflight guards make this safe to call on every build.
_maybeRequestCommute(state, timetableSettings);
if (_cachedAppointments != null &&
_lastDataVersion == state.dataVersion &&
identical(_lastTimetableSettings, timetableSettings)) {
identical(_lastTimetableSettings, timetableSettings) &&
identical(_lastCommuteState, commuteState)) {
return _cachedAppointments!;
}
_lastDataVersion = state.dataVersion;
_lastTimetableSettings = timetableSettings;
_lastCommuteState = commuteState;
return _cachedAppointments = TimetableAppointmentFactory(
final base = TimetableAppointmentFactory(
lessons: state.getAllKnownLessons().toList(),
customEvents: state.customEvents?.events ?? const [],
rooms: state.rooms!,
@@ -74,11 +85,74 @@ class _TimetableState extends State<Timetable> {
settings: timetableSettings,
now: DateTime.now(),
).build();
if (!timetableSettings.showCommuteInTimetable || commuteState.isEmpty) {
return _cachedAppointments = base;
}
final commute = <Appointment>[];
for (final entry in commuteState.values) {
commute.addAll(
CommuteAppointmentFactory.build(
morning: entry.morning,
evening: entry.evening,
),
);
}
return _cachedAppointments = [...base, ...commute];
}
void _maybeRequestCommute(
TimetableState state,
TimetableSettings timetableSettings,
) {
if (!timetableSettings.showCommuteInTimetable) return;
if (timetableSettings.homeStation == null) return;
if (timetableSettings.schoolStation == null) return;
final spans = _lessonSpansByDay(state);
if (spans.isEmpty) return;
context.read<CommuteCubit>().ensureLoaded(
lessonsByDay: spans,
settings: timetableSettings,
);
}
Map<DateTime, LessonSpan> _lessonSpansByDay(TimetableState state) {
final byDay = <DateTime, _MinMax>{};
for (final lesson in state.getAllKnownLessons()) {
try {
final start = WebuntisTime.parse(lesson.date, lesson.startTime);
final end = WebuntisTime.parse(lesson.date, lesson.endTime);
final day = DateTime(start.year, start.month, start.day);
final existing = byDay[day];
if (existing == null) {
byDay[day] = _MinMax(start, end);
} else {
if (start.isBefore(existing.min)) existing.min = start;
if (end.isAfter(existing.max)) existing.max = end;
}
} catch (_) {
// Skip lessons we can't parse — same fallback as elsewhere.
}
}
return {
for (final entry in byDay.entries)
entry.key: LessonSpan(entry.value.min, entry.value.max),
};
}
bool _isCrossedOut(Appointment appointment) {
final id = appointment.id;
if (id is WebuntisAppointment) return id.lesson.code == 'cancelled';
if (id is CommuteAppointment) {
// Strike the tile only if literally every leg is cancelled — partially
// cancelled trips still get the user somewhere and should stay legible.
final legs = id.trip.legs;
return legs.isNotEmpty &&
legs.every((l) => l.cancelled || l.partCancelled);
}
return false;
}
@@ -217,3 +291,9 @@ class _TimetableState extends State<Timetable> {
return (mondayMin, effectiveMax);
}
}
class _MinMax {
DateTime min;
DateTime max;
_MinMax(this.min, this.max);
}