From 46d6b3410eb06ae96fcc0b2cab9e2d39cb720ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 20 May 2026 22:50:57 +0200 Subject: [PATCH] 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 --- lib/api/geocoding/nominatim_result.dart | 16 + .../geocoding/nominatim_result.freezed.dart | 283 +++++++++++++ lib/api/geocoding/nominatim_result.g.dart | 21 + lib/api/geocoding/nominatim_search.dart | 72 ++++ lib/main.dart | 6 + .../modules/commute/bloc/commute_cubit.dart | 191 +++++++++ .../repository/commute_repository.dart | 40 ++ lib/storage/timetable_settings.dart | 30 +- lib/storage/timetable_settings.g.dart | 31 +- .../sections/commute_settings_section.dart | 397 ++++++++++++++++++ .../settings/sections/timetable_section.dart | 3 + .../timetable/data/arbitrary_appointment.dart | 11 + .../pages/timetable/data/calendar_logic.dart | 5 +- .../data/commute_appointment_factory.dart | 84 ++++ .../timetable/data/commute_direction.dart | 2 + .../appointment_details_dispatcher.dart | 3 + lib/view/pages/timetable/timetable.dart | 84 +++- .../timetable/widgets/appointment_tile.dart | 17 +- .../commute/commute_details_sheet.dart | 95 +++++ .../widgets/commute/commute_tile_content.dart | 142 +++++++ 20 files changed, 1513 insertions(+), 20 deletions(-) create mode 100644 lib/api/geocoding/nominatim_result.dart create mode 100644 lib/api/geocoding/nominatim_result.freezed.dart create mode 100644 lib/api/geocoding/nominatim_result.g.dart create mode 100644 lib/api/geocoding/nominatim_search.dart create mode 100644 lib/state/app/modules/commute/bloc/commute_cubit.dart create mode 100644 lib/state/app/modules/commute/repository/commute_repository.dart create mode 100644 lib/view/pages/settings/sections/commute_settings_section.dart create mode 100644 lib/view/pages/timetable/data/commute_appointment_factory.dart create mode 100644 lib/view/pages/timetable/data/commute_direction.dart create mode 100644 lib/view/pages/timetable/widgets/commute/commute_details_sheet.dart create mode 100644 lib/view/pages/timetable/widgets/commute/commute_tile_content.dart diff --git a/lib/api/geocoding/nominatim_result.dart b/lib/api/geocoding/nominatim_result.dart new file mode 100644 index 0000000..7e75c57 --- /dev/null +++ b/lib/api/geocoding/nominatim_result.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'nominatim_result.freezed.dart'; +part 'nominatim_result.g.dart'; + +@freezed +abstract class NominatimResult with _$NominatimResult { + const factory NominatimResult({ + required String displayName, + required double lat, + required double lon, + }) = _NominatimResult; + + factory NominatimResult.fromJson(Map json) => + _$NominatimResultFromJson(json); +} diff --git a/lib/api/geocoding/nominatim_result.freezed.dart b/lib/api/geocoding/nominatim_result.freezed.dart new file mode 100644 index 0000000..adac26f --- /dev/null +++ b/lib/api/geocoding/nominatim_result.freezed.dart @@ -0,0 +1,283 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'nominatim_result.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$NominatimResult { + + String get displayName; double get lat; double get lon; +/// Create a copy of NominatimResult +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NominatimResultCopyWith get copyWith => _$NominatimResultCopyWithImpl(this as NominatimResult, _$identity); + + /// Serializes this NominatimResult to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NominatimResult&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.lat, lat) || other.lat == lat)&&(identical(other.lon, lon) || other.lon == lon)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,displayName,lat,lon); + +@override +String toString() { + return 'NominatimResult(displayName: $displayName, lat: $lat, lon: $lon)'; +} + + +} + +/// @nodoc +abstract mixin class $NominatimResultCopyWith<$Res> { + factory $NominatimResultCopyWith(NominatimResult value, $Res Function(NominatimResult) _then) = _$NominatimResultCopyWithImpl; +@useResult +$Res call({ + String displayName, double lat, double lon +}); + + + + +} +/// @nodoc +class _$NominatimResultCopyWithImpl<$Res> + implements $NominatimResultCopyWith<$Res> { + _$NominatimResultCopyWithImpl(this._self, this._then); + + final NominatimResult _self; + final $Res Function(NominatimResult) _then; + +/// Create a copy of NominatimResult +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? displayName = null,Object? lat = null,Object? lon = null,}) { + return _then(_self.copyWith( +displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable +as String,lat: null == lat ? _self.lat : lat // ignore: cast_nullable_to_non_nullable +as double,lon: null == lon ? _self.lon : lon // ignore: cast_nullable_to_non_nullable +as double, + )); +} + +} + + +/// Adds pattern-matching-related methods to [NominatimResult]. +extension NominatimResultPatterns on NominatimResult { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _NominatimResult value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _NominatimResult() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _NominatimResult value) $default,){ +final _that = this; +switch (_that) { +case _NominatimResult(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _NominatimResult value)? $default,){ +final _that = this; +switch (_that) { +case _NominatimResult() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String displayName, double lat, double lon)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _NominatimResult() when $default != null: +return $default(_that.displayName,_that.lat,_that.lon);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String displayName, double lat, double lon) $default,) {final _that = this; +switch (_that) { +case _NominatimResult(): +return $default(_that.displayName,_that.lat,_that.lon);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String displayName, double lat, double lon)? $default,) {final _that = this; +switch (_that) { +case _NominatimResult() when $default != null: +return $default(_that.displayName,_that.lat,_that.lon);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _NominatimResult implements NominatimResult { + const _NominatimResult({required this.displayName, required this.lat, required this.lon}); + factory _NominatimResult.fromJson(Map json) => _$NominatimResultFromJson(json); + +@override final String displayName; +@override final double lat; +@override final double lon; + +/// Create a copy of NominatimResult +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$NominatimResultCopyWith<_NominatimResult> get copyWith => __$NominatimResultCopyWithImpl<_NominatimResult>(this, _$identity); + +@override +Map toJson() { + return _$NominatimResultToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _NominatimResult&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.lat, lat) || other.lat == lat)&&(identical(other.lon, lon) || other.lon == lon)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,displayName,lat,lon); + +@override +String toString() { + return 'NominatimResult(displayName: $displayName, lat: $lat, lon: $lon)'; +} + + +} + +/// @nodoc +abstract mixin class _$NominatimResultCopyWith<$Res> implements $NominatimResultCopyWith<$Res> { + factory _$NominatimResultCopyWith(_NominatimResult value, $Res Function(_NominatimResult) _then) = __$NominatimResultCopyWithImpl; +@override @useResult +$Res call({ + String displayName, double lat, double lon +}); + + + + +} +/// @nodoc +class __$NominatimResultCopyWithImpl<$Res> + implements _$NominatimResultCopyWith<$Res> { + __$NominatimResultCopyWithImpl(this._self, this._then); + + final _NominatimResult _self; + final $Res Function(_NominatimResult) _then; + +/// Create a copy of NominatimResult +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? displayName = null,Object? lat = null,Object? lon = null,}) { + return _then(_NominatimResult( +displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable +as String,lat: null == lat ? _self.lat : lat // ignore: cast_nullable_to_non_nullable +as double,lon: null == lon ? _self.lon : lon // ignore: cast_nullable_to_non_nullable +as double, + )); +} + + +} + +// dart format on diff --git a/lib/api/geocoding/nominatim_result.g.dart b/lib/api/geocoding/nominatim_result.g.dart new file mode 100644 index 0000000..96fff82 --- /dev/null +++ b/lib/api/geocoding/nominatim_result.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'nominatim_result.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_NominatimResult _$NominatimResultFromJson(Map json) => + _NominatimResult( + displayName: json['displayName'] as String, + lat: (json['lat'] as num).toDouble(), + lon: (json['lon'] as num).toDouble(), + ); + +Map _$NominatimResultToJson(_NominatimResult instance) => + { + 'displayName': instance.displayName, + 'lat': instance.lat, + 'lon': instance.lon, + }; diff --git a/lib/api/geocoding/nominatim_search.dart b/lib/api/geocoding/nominatim_search.dart new file mode 100644 index 0000000..a2c156b --- /dev/null +++ b/lib/api/geocoding/nominatim_search.dart @@ -0,0 +1,72 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../errors/network_exception.dart'; +import '../errors/parse_exception.dart'; +import '../errors/server_exception.dart'; +import 'nominatim_result.dart'; + +/// Tiny wrapper around the public Nominatim geocoder. Only used in the +/// commute-settings flow to look up a home address; not called from any +/// hot path. The User-Agent header is **required** by the Nominatim usage +/// policy — without it the service throttles/blocks the client. +class NominatimSearch { + static const _userAgent = 'MarianumMobile/1.0 (contact@elias-mueller.com)'; + static final Uri _base = Uri.parse('https://nominatim.openstreetmap.org/search'); + + /// Returns up to [limit] geocoded matches for the user-typed [query]. + Future> run(String query, {int limit = 5}) async { + final uri = _base.replace( + queryParameters: { + 'q': query, + 'format': 'json', + 'limit': limit.toString(), + 'addressdetails': '0', + 'accept-language': 'de', + }, + ); + + final http.Response response; + try { + response = await http + .get(uri, headers: {'User-Agent': _userAgent, 'Accept': 'application/json'}) + .timeout(const Duration(seconds: 15)); + } on SocketException catch (e) { + throw NetworkException(technicalDetails: 'nominatim: ${e.message}'); + } on TimeoutException catch (e) { + throw NetworkException.timeout(technicalDetails: 'nominatim: $e'); + } on http.ClientException catch (e) { + throw NetworkException(technicalDetails: 'nominatim: ${e.message}'); + } + + if (response.statusCode > 299) { + throw ServerException( + statusCode: response.statusCode, + technicalDetails: 'nominatim HTTP ${response.statusCode}', + ); + } + + try { + final raw = jsonDecode(utf8.decode(response.bodyBytes)) as List; + return raw + .map((e) => _resultFromRaw(e as Map)) + .toList(growable: false); + } catch (e) { + throw ParseException(technicalDetails: 'nominatim assemble: $e'); + } + } + + static NominatimResult _resultFromRaw(Map json) { + // Nominatim returns lat/lon as strings, not numbers. Normalise here. + final lat = double.parse(json['lat'].toString()); + final lon = double.parse(json['lon'].toString()); + return NominatimResult( + displayName: json['display_name'] as String? ?? '?', + lat: lat, + lon: lon, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index cc7d2f8..6c910dd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -31,6 +31,7 @@ import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/chat/bloc/chat_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.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 'storage/settings.dart'; @@ -159,6 +160,7 @@ Future main() async { create: (ctx) => ChatBloc(chatListBloc: ctx.read()), ), BlocProvider(create: (_) => TimetableBloc()), + BlocProvider(create: (_) => CommuteCubit()), ], child: const Main(), ), @@ -252,6 +254,7 @@ class _MainState extends State
{ final chatListBloc = context.read(); final chatBloc = context.read(); final breakerBloc = context.read(); + final commuteCubit = context.read(); // Defer the actual wipe until after this frame so the // App tree (TimetableBloc/ChatListBloc watchers etc.) // is already torn down. Resetting blocs while App is @@ -264,6 +267,7 @@ class _MainState extends State
{ chatListBloc: chatListBloc, chatBloc: chatBloc, breakerBloc: breakerBloc, + commuteCubit: commuteCubit, ), ); }); @@ -308,6 +312,7 @@ Future _wipeUserState({ required ChatListBloc chatListBloc, required ChatBloc chatBloc, required BreakerBloc breakerBloc, + required CommuteCubit commuteCubit, }) async { try { // Reset user-data blocs whose tree is no longer mounted after the @@ -320,6 +325,7 @@ Future _wipeUserState({ chatListBloc.reset(), chatBloc.reset(), breakerBloc.reset(), + commuteCubit.reset(), ConnectAuthStore.instance.clear(), ]); final prefs = await SharedPreferences.getInstance(); diff --git a/lib/state/app/modules/commute/bloc/commute_cubit.dart b/lib/state/app/modules/commute/bloc/commute_cubit.dart new file mode 100644 index 0000000..55a95ca --- /dev/null +++ b/lib/state/app/modules/commute/bloc/commute_cubit.dart @@ -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 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 {}); + } +} diff --git a/lib/state/app/modules/commute/repository/commute_repository.dart b/lib/state/app/modules/commute/repository/commute_repository.dart new file mode 100644 index 0000000..c7c1524 --- /dev/null +++ b/lib/state/app/modules/commute/repository/commute_repository.dart @@ -0,0 +1,40 @@ +import '../../../../../api/connect/rmv/queries/search_stops.dart'; +import '../../../../../api/connect/rmv/rmv_models.dart'; +import '../../../../../state/app/modules/rmv/repository/rmv_repository.dart'; + +class CommuteRepository { + final RmvRepository _rmv = RmvRepository(); + + /// Trip search wrapper. Morning runs use `byArrival=true` with the latest + /// acceptable arrival time; evening runs use `byArrival=false` with the + /// earliest acceptable departure time. + Future> findTrips({ + required StopLocation from, + required StopLocation to, + required DateTime when, + required bool byArrival, + int max = 3, + }) async { + final result = await _rmv.searchTrips( + fromStopId: from.id, + toStopId: to.id, + when: when, + searchByArrival: byArrival, + ); + if (result.trips.length <= max) return result.trips; + return result.trips.sublist(0, max); + } + + /// Best-effort default school station lookup. Used the first time the user + /// activates the commute toggle, before they've had a chance to pick one + /// manually. Returns the first hit whose name contains "Marianum", or + /// just the first hit, or `null` if the search returned nothing. + Future resolveDefaultSchoolStation() async { + final results = await SearchStops(query: 'Marianum Fulda', max: 5).run(); + if (results.isEmpty) return null; + final marianum = results.where( + (s) => s.name.toLowerCase().contains('marianum'), + ); + return marianum.isNotEmpty ? marianum.first : results.first; + } +} diff --git a/lib/storage/timetable_settings.dart b/lib/storage/timetable_settings.dart index feaa007..4d9652b 100644 --- a/lib/storage/timetable_settings.dart +++ b/lib/storage/timetable_settings.dart @@ -1,17 +1,43 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../view/pages/timetable/data/timetable_name_mode.dart'; +import '../api/connect/rmv/rmv_models.dart'; +import '../view/pages/timetable/data/timetable_name_mode.dart'; part 'timetable_settings.g.dart'; -@JsonSerializable() +@JsonSerializable(explicitToJson: true) class TimetableSettings { bool connectDoubleLessons; TimetableNameMode timetableNameMode; + /// Show RMV transit cards before the first lesson and after the last lesson + /// of each day. + bool showCommuteInTimetable; + + /// The user's home RMV stop. Resolved via Nominatim → nearbyStops in the + /// settings flow; once stored it is used directly for all trip lookups. + StopLocation? homeStation; + + /// Free-text label of the address the user entered (for display purposes + /// only — the actual lookup uses [homeStation]). + String? homeAddressLabel; + + /// School-side stop. Default-resolved via `searchStops("Fulda Marianum")` + /// on the first activation of [showCommuteInTimetable]. + StopLocation? schoolStation; + + /// Minutes added as a walking buffer between the stop and the first lesson + /// (and analogously after the last lesson). + int commuteBufferMinutes; + TimetableSettings({ required this.connectDoubleLessons, required this.timetableNameMode, + this.showCommuteInTimetable = false, + this.homeStation, + this.homeAddressLabel, + this.schoolStation, + this.commuteBufferMinutes = 5, }); factory TimetableSettings.fromJson(Map json) => diff --git a/lib/storage/timetable_settings.g.dart b/lib/storage/timetable_settings.g.dart index 87c60d7..3f24501 100644 --- a/lib/storage/timetable_settings.g.dart +++ b/lib/storage/timetable_settings.g.dart @@ -6,20 +6,35 @@ part of 'timetable_settings.dart'; // JsonSerializableGenerator // ************************************************************************** -TimetableSettings _$TimetableSettingsFromJson(Map json) => - TimetableSettings( - connectDoubleLessons: json['connectDoubleLessons'] as bool, - timetableNameMode: $enumDecode( - _$TimetableNameModeEnumMap, - json['timetableNameMode'], - ), - ); +TimetableSettings _$TimetableSettingsFromJson( + Map json, +) => TimetableSettings( + connectDoubleLessons: json['connectDoubleLessons'] as bool, + timetableNameMode: $enumDecode( + _$TimetableNameModeEnumMap, + json['timetableNameMode'], + ), + showCommuteInTimetable: json['showCommuteInTimetable'] as bool? ?? false, + homeStation: json['homeStation'] == null + ? null + : StopLocation.fromJson(json['homeStation'] as Map), + homeAddressLabel: json['homeAddressLabel'] as String?, + schoolStation: json['schoolStation'] == null + ? null + : StopLocation.fromJson(json['schoolStation'] as Map), + commuteBufferMinutes: (json['commuteBufferMinutes'] as num?)?.toInt() ?? 5, +); Map _$TimetableSettingsToJson( TimetableSettings instance, ) => { 'connectDoubleLessons': instance.connectDoubleLessons, 'timetableNameMode': _$TimetableNameModeEnumMap[instance.timetableNameMode]!, + 'showCommuteInTimetable': instance.showCommuteInTimetable, + 'homeStation': instance.homeStation?.toJson(), + 'homeAddressLabel': instance.homeAddressLabel, + 'schoolStation': instance.schoolStation?.toJson(), + 'commuteBufferMinutes': instance.commuteBufferMinutes, }; const _$TimetableNameModeEnumMap = { diff --git a/lib/view/pages/settings/sections/commute_settings_section.dart b/lib/view/pages/settings/sections/commute_settings_section.dart new file mode 100644 index 0000000..cae45ad --- /dev/null +++ b/lib/view/pages/settings/sections/commute_settings_section.dart @@ -0,0 +1,397 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/connect/rmv/rmv_models.dart'; +import '../../../../api/errors/error_mapper.dart'; +import '../../../../api/geocoding/nominatim_result.dart'; +import '../../../../api/geocoding/nominatim_search.dart'; +import '../../../../state/app/modules/commute/repository/commute_repository.dart'; +import '../../../../state/app/modules/rmv/repository/rmv_repository.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../widget/app_progress_indicator.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../rmv/widgets/station_picker_sheet.dart'; + +/// Settings block for the timetable-commute prototype. Toggle + home address +/// flow (Nominatim → nearbyStops) + school station picker + walking buffer. +class CommuteSettingsSection extends StatelessWidget { + const CommuteSettingsSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final s = settings.val().timetableSettings; + return Column( + children: [ + SwitchListTile( + secondary: const Icon(Icons.directions_bus_outlined), + title: const Text('Pendel-Verbindung im Stundenplan'), + subtitle: const Text( + 'Zeigt für jeden Schultag die ÖPNV-Verbindung von und zur Schule.', + ), + value: s.showCommuteInTimetable, + onChanged: (v) => _toggle(context, v), + ), + if (s.showCommuteInTimetable) ...[ + ListTile( + leading: const CenteredLeading(Icon(Icons.home_outlined)), + title: const Text('Heimat-Haltestelle'), + subtitle: Text(_homeSubtitle(s.homeAddressLabel, s.homeStation)), + trailing: const Icon(Icons.edit_outlined), + onTap: () => _editHome(context), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.school_outlined)), + title: const Text('Schul-Haltestelle'), + subtitle: Text(s.schoolStation?.name ?? 'Noch nicht gesetzt'), + trailing: const Icon(Icons.edit_outlined), + onTap: () => _editSchool(context), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.timer_outlined)), + title: const Text('Pufferzeit'), + subtitle: Text( + '${s.commuteBufferMinutes} Min Fußweg vor/nach dem Schultag', + ), + trailing: const Icon(Icons.edit_outlined), + onTap: () => _editBuffer(context), + ), + ], + ], + ); + } + + String _homeSubtitle(String? label, StopLocation? home) { + if (home == null) return 'Noch nicht gesetzt'; + if (label == null || label.isEmpty) return home.name; + return '${home.name}\n($label)'; + } + + Future _toggle(BuildContext context, bool value) async { + final settings = context.read(); + settings.val(write: true).timetableSettings.showCommuteInTimetable = value; + if (!value) return; + final current = settings.val().timetableSettings.schoolStation; + if (current != null) return; + // Best-effort default resolve so the user doesn't have to pick the + // school station manually if the RMV knows "Marianum". + try { + final resolved = await CommuteRepository().resolveDefaultSchoolStation(); + if (resolved == null || !context.mounted) return; + settings.val(write: true).timetableSettings.schoolStation = resolved; + } catch (_) { + // Silent: settings tile still shows "Noch nicht gesetzt" + edit option. + } + } + + Future _editSchool(BuildContext context) async { + final picked = await showStationPickerSheet( + context, + title: 'Schul-Haltestelle wählen', + ); + if (picked == null || !context.mounted) return; + context.read().val(write: true).timetableSettings.schoolStation = picked; + } + + Future _editBuffer(BuildContext context) async { + final settings = context.read(); + final current = settings.val().timetableSettings.commuteBufferMinutes; + final picked = await showDialog( + context: context, + builder: (_) => _BufferPickerDialog(initial: current), + ); + if (picked == null) return; + settings.val(write: true).timetableSettings.commuteBufferMinutes = picked; + } + + Future _editHome(BuildContext context) async { + final picked = await showModalBottomSheet<_HomeSelection>( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + builder: (_) => const _HomeAddressFlow(), + ); + if (picked == null || !context.mounted) return; + final settings = context.read(); + settings.val(write: true).timetableSettings + ..homeAddressLabel = picked.addressLabel + ..homeStation = picked.stop; + } +} + +class _HomeSelection { + final String addressLabel; + final StopLocation stop; + const _HomeSelection({required this.addressLabel, required this.stop}); +} + +class _BufferPickerDialog extends StatefulWidget { + final int initial; + const _BufferPickerDialog({required this.initial}); + + @override + State<_BufferPickerDialog> createState() => _BufferPickerDialogState(); +} + +class _BufferPickerDialogState extends State<_BufferPickerDialog> { + late double _value = widget.initial.toDouble(); + + @override + Widget build(BuildContext context) => AlertDialog( + title: const Text('Pufferzeit'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${_value.round()} Minuten Fußweg', + style: Theme.of(context).textTheme.titleLarge, + ), + Slider( + min: 0, + max: 30, + divisions: 30, + value: _value, + label: '${_value.round()} min', + onChanged: (v) => setState(() => _value = v), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(_value.round()), + child: const Text('Übernehmen'), + ), + ], + ); +} + +/// Two-step picker: address search via Nominatim → choose address → +/// nearbyStops → choose stop. Returns the chosen [_HomeSelection] via +/// Navigator.pop, or null if dismissed. +class _HomeAddressFlow extends StatefulWidget { + const _HomeAddressFlow(); + + @override + State<_HomeAddressFlow> createState() => _HomeAddressFlowState(); +} + +class _HomeAddressFlowState extends State<_HomeAddressFlow> { + final _queryCtrl = TextEditingController(); + final NominatimSearch _geo = NominatimSearch(); + final RmvRepository _rmv = RmvRepository(); + + List? _addresses; + List? _stops; + NominatimResult? _chosenAddress; + bool _loading = false; + Object? _error; + + @override + void dispose() { + _queryCtrl.dispose(); + super.dispose(); + } + + Future _searchAddress() async { + final q = _queryCtrl.text.trim(); + if (q.length < 3) return; + setState(() { + _loading = true; + _error = null; + _stops = null; + _chosenAddress = null; + }); + try { + final res = await _geo.run(q, limit: 5); + if (!mounted) return; + setState(() { + _addresses = res; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e; + _loading = false; + }); + } + } + + Future _pickAddress(NominatimResult addr) async { + setState(() { + _chosenAddress = addr; + _loading = true; + _error = null; + }); + try { + final stops = await _rmv.nearbyStops( + lat: addr.lat, + lon: addr.lon, + radiusMeters: 800, + max: 8, + ); + if (!mounted) return; + stops.sort( + (a, b) => (a.distanceMeters ?? 0).compareTo(b.distanceMeters ?? 0), + ); + setState(() { + _stops = stops; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e; + _loading = false; + }); + } + } + + void _pickStop(StopLocation stop) { + final addr = _chosenAddress; + if (addr == null) return; + Navigator.of(context).pop( + _HomeSelection(addressLabel: addr.displayName, stop: stop), + ); + } + + @override + Widget build(BuildContext context) { + final viewInsets = MediaQuery.of(context).viewInsets; + return Padding( + padding: EdgeInsets.only(bottom: viewInsets.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Text( + 'Heimadresse einrichten', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Adresse wird zur Suche an OpenStreetMap (Nominatim) übermittelt.', + style: TextStyle(fontSize: 12), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _queryCtrl, + autofocus: true, + textInputAction: TextInputAction.search, + onSubmitted: (_) => _searchAddress(), + decoration: InputDecoration( + hintText: 'Straße, Hausnr., Ort', + prefixIcon: const Icon(Icons.home_outlined), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _loading ? null : _searchAddress, + child: const Text('Suchen'), + ), + ], + ), + ), + const SizedBox(height: 8), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.55, + minHeight: 180, + ), + child: _body(), + ), + ], + ), + ); + } + + Widget _body() { + if (_loading) return const Center(child: AppProgressIndicator.medium()); + if (_error != null) { + return Padding( + padding: const EdgeInsets.all(16), + child: Text( + errorToUserMessage(_error), + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ); + } + final stops = _stops; + if (stops != null) return _stopsList(stops); + final addresses = _addresses; + if (addresses != null) return _addressList(addresses); + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text( + 'Gib deine Adresse ein und tippe "Suchen", um die nächstgelegenen RMV-Haltestellen zu finden.', + textAlign: TextAlign.center, + ), + ), + ); + } + + Widget _addressList(List addresses) { + if (addresses.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('Keine Adresse zur Suche gefunden.'), + ), + ); + } + return ListView.separated( + itemCount: addresses.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) => ListTile( + leading: const CenteredLeading(Icon(Icons.place_outlined)), + title: Text(addresses[i].displayName), + onTap: () => _pickAddress(addresses[i]), + ), + ); + } + + Widget _stopsList(List stops) { + if (stops.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('Keine Haltestelle in 800 m Umkreis gefunden.'), + ), + ); + } + return ListView.separated( + itemCount: stops.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) { + final s = stops[i]; + return ListTile( + leading: const CenteredLeading(Icon(Icons.directions_transit)), + title: Text(s.name), + subtitle: s.distanceMeters == null + ? null + : Text('${s.distanceMeters} m'), + onTap: () => _pickStop(s), + ); + }, + ); + } +} diff --git a/lib/view/pages/settings/sections/timetable_section.dart b/lib/view/pages/settings/sections/timetable_section.dart index 044cca0..5d2b1ee 100644 --- a/lib/view/pages/settings/sections/timetable_section.dart +++ b/lib/view/pages/settings/sections/timetable_section.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; +import 'commute_settings_section.dart'; class TimetableSection extends StatelessWidget { const TimetableSection({super.key}); @@ -54,6 +55,8 @@ class TimetableSection extends StatelessWidget { e!, ), ), + const Divider(height: 1), + const CommuteSettingsSection(), ], ); } diff --git a/lib/view/pages/timetable/data/arbitrary_appointment.dart b/lib/view/pages/timetable/data/arbitrary_appointment.dart index 1f8dd82..e081347 100644 --- a/lib/view/pages/timetable/data/arbitrary_appointment.dart +++ b/lib/view/pages/timetable/data/arbitrary_appointment.dart @@ -1,5 +1,7 @@ +import '../../../../api/connect/rmv/rmv_models.dart'; import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import 'commute_direction.dart'; sealed class ArbitraryAppointment { const ArbitraryAppointment(); @@ -7,9 +9,12 @@ sealed class ArbitraryAppointment { T when({ required T Function(GetTimetableResponseObject lesson) webuntis, required T Function(CustomTimetableEvent event) custom, + required T Function(Trip trip, CommuteDirection direction) commute, }) => switch (this) { WebuntisAppointment(:final lesson) => webuntis(lesson), CustomAppointment(:final event) => custom(event), + CommuteAppointment(:final trip, :final direction) => + commute(trip, direction), }; } @@ -22,3 +27,9 @@ class CustomAppointment extends ArbitraryAppointment { final CustomTimetableEvent event; const CustomAppointment(this.event); } + +class CommuteAppointment extends ArbitraryAppointment { + final Trip trip; + final CommuteDirection direction; + const CommuteAppointment(this.trip, this.direction); +} diff --git a/lib/view/pages/timetable/data/calendar_logic.dart b/lib/view/pages/timetable/data/calendar_logic.dart index 8560876..f2a359e 100644 --- a/lib/view/pages/timetable/data/calendar_logic.dart +++ b/lib/view/pages/timetable/data/calendar_logic.dart @@ -282,8 +282,9 @@ class LaidOutOverflow extends LaidOutCell { int _appointmentPriority(Appointment a) { final id = a.id; if (id is CustomAppointment) return 0; - if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1; - return 2; + if (id is CommuteAppointment) return 1; + if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 2; + return 3; } /// Assigns each appointment a lane index using a greedy sweep, then collapses diff --git a/lib/view/pages/timetable/data/commute_appointment_factory.dart b/lib/view/pages/timetable/data/commute_appointment_factory.dart new file mode 100644 index 0000000..0acd1f1 --- /dev/null +++ b/lib/view/pages/timetable/data/commute_appointment_factory.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../api/connect/rmv/rmv_models.dart'; +import '../../../../extensions/date_time.dart'; +import 'arbitrary_appointment.dart'; +import 'commute_direction.dart'; + +/// Builds [Appointment] objects from RMV [Trip]s so the existing timetable +/// lane layout can render them next to school lessons. Per-trip cancellation +/// flips the color to red; the [appointment.id] always wraps the original +/// [Trip] so the tap handler can surface its details. +class CommuteAppointmentFactory { + static const Color colorMorning = Color(0xFFFB8C00); // amber 600 + static const Color colorEvening = Color(0xFF8E24AA); // purple 600 + static const Color colorCancelled = Color(0xFFE53935); // red 600 + + /// Converts every entry in [morning]/[evening] into an Appointment. + static List build({ + required List morning, + required List evening, + }) => [ + for (final trip in morning) ?_tripToAppointment(trip, CommuteDirection.toSchool), + for (final trip in evening) ?_tripToAppointment(trip, CommuteDirection.fromSchool), + ]; + + static Appointment? _tripToAppointment(Trip trip, CommuteDirection direction) { + final firstLeg = trip.legs.firstOrNull; + final lastLeg = trip.legs.lastOrNull; + if (firstLeg == null || lastLeg == null) return null; + final start = firstLeg.origin.scheduledTime; + final end = lastLeg.destination.scheduledTime; + if (!end.isAfter(start)) return null; + + final cancelled = + trip.legs.every((l) => l.cancelled || l.partCancelled) && + trip.legs.isNotEmpty; + final color = cancelled + ? colorCancelled + : (direction == CommuteDirection.toSchool + ? colorMorning + : colorEvening); + + return Appointment( + id: CommuteAppointment(trip, direction), + startTime: start, + endTime: end, + subject: _subject(trip), + location: _location(direction, start, end), + color: color, + startTimeZone: '', + endTimeZone: '', + ); + } + + static String _subject(Trip trip) { + final lines = trip.legs + .where((l) => l.type == LegType.journey) + .map(_legLabel) + .where((s) => s.isNotEmpty) + .toList(); + if (lines.isEmpty) return 'Fußweg'; + return lines.join(' › '); + } + + static String _legLabel(Leg leg) { + final p = leg.product; + if (p == null) return leg.name ?? '?'; + if (p.line != null && p.line!.isNotEmpty) return p.line!; + if (p.displayNumber != null && p.displayNumber!.isNotEmpty) { + return '${p.category ?? ''}${p.displayNumber}'.trim(); + } + return p.name ?? leg.name ?? '?'; + } + + static String _location( + CommuteDirection direction, + DateTime start, + DateTime end, + ) { + final label = direction == CommuteDirection.toSchool ? 'Hinfahrt' : 'Heimfahrt'; + return '$label\n${start.formatHm()}–${end.formatHm()}'; + } +} diff --git a/lib/view/pages/timetable/data/commute_direction.dart b/lib/view/pages/timetable/data/commute_direction.dart new file mode 100644 index 0000000..9244a38 --- /dev/null +++ b/lib/view/pages/timetable/data/commute_direction.dart @@ -0,0 +1,2 @@ +/// Direction of a commute trip relative to the school day. +enum CommuteDirection { toSchool, fromSchool } diff --git a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart index f1ce427..c313f55 100644 --- a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart +++ b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart @@ -3,6 +3,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; import '../data/arbitrary_appointment.dart'; +import '../widgets/commute/commute_details_sheet.dart'; import 'custom_event_sheet.dart'; import 'webuntis_lesson_sheet.dart'; @@ -19,6 +20,8 @@ class AppointmentDetailsDispatcher { webuntis: (lesson) => WebuntisLessonSheet.show(context, bloc, appointment, lesson), custom: (event) => CustomEventSheet.show(context, event), + commute: (trip, direction) => + showCommuteDetailsSheet(context, trip: trip, direction: direction), ); } } diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 8104ba0..77156ba 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -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 { List? _cachedAppointments; int? _lastDataVersion; TimetableSettings? _lastTimetableSettings; + Map? _lastCommuteState; DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2)); @@ -58,15 +61,23 @@ class _TimetableState extends State { .watch() .val() .timetableSettings; + final commuteState = context.watch().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 { settings: timetableSettings, now: DateTime.now(), ).build(); + + if (!timetableSettings.showCommuteInTimetable || commuteState.isEmpty) { + return _cachedAppointments = base; + } + + final commute = []; + 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().ensureLoaded( + lessonsByDay: spans, + settings: timetableSettings, + ); + } + + Map _lessonSpansByDay(TimetableState state) { + final byDay = {}; + 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 { return (mondayMin, effectiveMax); } } + +class _MinMax { + DateTime min; + DateTime max; + _MinMax(this.min, this.max); +} diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart index 0d8c1cf..dc78106 100644 --- a/lib/view/pages/timetable/widgets/appointment_tile.dart +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -3,6 +3,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart'; import '../data/arbitrary_appointment.dart'; import '../data/calendar_layout.dart'; +import 'commute/commute_tile_content.dart'; import 'cross_painter.dart'; class AppointmentTile extends StatelessWidget { @@ -21,7 +22,9 @@ class AppointmentTile extends StatelessWidget { Widget build(BuildContext context) { final isPast = appointment.endTime.isBefore(DateTime.now()); final color = appointment.color.withAlpha(isPast ? 160 : 255); - final isCustom = appointment.id is CustomAppointment; + final id = appointment.id; + final isCustom = id is CustomAppointment; + final isCommute = id is CommuteAppointment; final description = appointment.location ?? ''; return Padding( @@ -37,11 +40,13 @@ class AppointmentTile extends StatelessWidget { borderRadius: _radius, color: color, ), - child: _TileContent( - title: appointment.subject, - description: description, - isCustom: isCustom, - ), + child: isCommute + ? CommuteTileContent(commute: id, crossedOut: crossedOut) + : _TileContent( + title: appointment.subject, + description: description, + isCustom: isCustom, + ), ), ), if (crossedOut) diff --git a/lib/view/pages/timetable/widgets/commute/commute_details_sheet.dart b/lib/view/pages/timetable/widgets/commute/commute_details_sheet.dart new file mode 100644 index 0000000..157b5ad --- /dev/null +++ b/lib/view/pages/timetable/widgets/commute/commute_details_sheet.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; + +import '../../../../../api/connect/rmv/rmv_models.dart'; +import '../../../../../extensions/date_time.dart'; +import '../../../../../routing/app_routes.dart'; +import '../../../../../widget/details_bottom_sheet.dart'; +import '../../../rmv/widgets/leg_tile.dart'; +import '../../../rmv/widgets/realtime_time.dart'; +import '../../data/commute_direction.dart'; + +/// Reuses the RMV-module LegTile so the in-timetable trip detail looks +/// identical to the regular trip-details view in the RMV module. +void showCommuteDetailsSheet( + BuildContext context, { + required Trip trip, + required CommuteDirection direction, +}) { + final firstLeg = trip.legs.firstOrNull; + final lastLeg = trip.legs.lastOrNull; + + showDetailsBottomSheet( + context, + header: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + direction == CommuteDirection.toSchool + ? 'Hinfahrt zur Schule' + : 'Heimfahrt', + style: Theme.of(context).textTheme.titleLarge, + ), + if (firstLeg != null && lastLeg != null) ...[ + const SizedBox(height: 4), + Text( + '${firstLeg.origin.name} → ${lastLeg.destination.name}', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 4), + Row( + children: [ + RealtimeTime( + scheduled: firstLeg.origin.scheduledTime, + realtime: firstLeg.origin.realTime, + delayMinutes: firstLeg.origin.delayMinutes?.toInt(), + style: Theme.of(context).textTheme.titleMedium, + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('–'), + ), + RealtimeTime( + scheduled: lastLeg.destination.scheduledTime, + realtime: lastLeg.destination.realTime, + delayMinutes: lastLeg.destination.delayMinutes?.toInt(), + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + if (trip.realDuration != null || trip.duration != null) + Text( + _formatDuration(trip.realDuration ?? trip.duration!), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ], + ], + ), + ), + children: (sheetCtx) => [ + ...trip.legs.map( + (l) => LegTile( + leg: l, + onShowJourneyDetail: l.journeyRef == null + ? null + : () => AppRoutes.openRmvJourneyDetail( + sheetCtx, + l.journeyRef!, + date: l.origin.scheduledTime, + ), + ), + ), + ], + ); +} + +String _formatDuration(Duration d) { + final h = d.inHours; + final m = d.inMinutes.remainder(60); + return h == 0 ? '$m min' : '$h h ${m.toString().padLeft(2, '0')} min'; +} + +/// Used in headers to label the trip start date relative to today. +String formatCommuteDay(DateTime day) => day.formatDateRelativeShort(); diff --git a/lib/view/pages/timetable/widgets/commute/commute_tile_content.dart b/lib/view/pages/timetable/widgets/commute/commute_tile_content.dart new file mode 100644 index 0000000..6e14fde --- /dev/null +++ b/lib/view/pages/timetable/widgets/commute/commute_tile_content.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; + +import '../../../../../api/connect/rmv/rmv_models.dart'; +import '../../../../../extensions/date_time.dart'; +import '../../data/arbitrary_appointment.dart'; +import '../../data/commute_direction.dart'; + +/// Tile body for [CommuteAppointment]s: bus icon + line label up top, real-time +/// departure with delay marker below. Designed to stay readable in 60–80 px +/// tall lanes — collapses gracefully when the lane is shorter. +class CommuteTileContent extends StatelessWidget { + final CommuteAppointment commute; + final bool crossedOut; + + const CommuteTileContent({ + super.key, + required this.commute, + this.crossedOut = false, + }); + + @override + Widget build(BuildContext context) { + final trip = commute.trip; + final firstLeg = trip.legs.firstOrNull; + if (firstLeg == null) return const SizedBox.shrink(); + + final scheduled = firstLeg.origin.scheduledTime; + final realtime = firstLeg.origin.realTime; + final delay = firstLeg.origin.delayMinutes?.toInt(); + final lineLabel = _lineLabel(trip); + final track = (firstLeg.origin.realTrack?.isNotEmpty ?? false) + ? firstLeg.origin.realTrack + : firstLeg.origin.track; + final cancelled = crossedOut; + + final dirIcon = commute.direction == CommuteDirection.toSchool + ? Icons.school_outlined + : Icons.home_outlined; + final dirLabel = commute.direction == CommuteDirection.toSchool + ? '→ Schule' + : '→ Heimat'; + + return LayoutBuilder( + builder: (context, constraints) { + final h = constraints.maxHeight; + if (h < 14) return const SizedBox.shrink(); + final children = [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(dirIcon, size: 12, color: Colors.white), + const SizedBox(width: 3), + Expanded( + child: Text( + '$lineLabel $dirLabel', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + height: 1.1, + decoration: + cancelled ? TextDecoration.lineThrough : null, + ), + ), + ), + ], + ), + ]; + if (h >= 28) { + children.add(const SizedBox(height: 1)); + children.add(_timeRow(scheduled, realtime, delay, cancelled)); + } + if (h >= 42 && track != null && track.isNotEmpty) { + children.add( + Padding( + padding: const EdgeInsets.only(top: 1), + child: Text( + 'Gleis $track', + style: const TextStyle(color: Colors.white70, fontSize: 9, height: 1.1), + ), + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: children, + ); + }, + ); + } + + Widget _timeRow( + DateTime scheduled, + DateTime? realtime, + int? delay, + bool cancelled, + ) { + final baseStyle = TextStyle( + color: Colors.white, + fontSize: 10, + height: 1.1, + decoration: cancelled ? TextDecoration.lineThrough : null, + ); + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(scheduled.formatHm(), style: baseStyle), + if (delay != null && delay != 0) ...[ + const SizedBox(width: 3), + Text( + '${delay > 0 ? '+' : ''}$delay\'', + style: baseStyle.copyWith( + color: delay > 0 ? const Color(0xFFFFCDD2) : const Color(0xFFC8E6C9), + fontWeight: FontWeight.w700, + decoration: null, + ), + ), + ], + ], + ); + } + + String _lineLabel(Trip trip) { + final journeys = trip.legs.where((l) => l.type == LegType.journey).toList(); + if (journeys.isEmpty) return 'Fußweg'; + return journeys.map(_legLabel).join(' › '); + } + + String _legLabel(Leg leg) { + final p = leg.product; + if (p == null) return leg.name ?? '?'; + if (p.line != null && p.line!.isNotEmpty) return p.line!; + if (p.displayNumber != null && p.displayNumber!.isNotEmpty) { + return '${p.category ?? ''}${p.displayNumber}'.trim(); + } + return p.name ?? leg.name ?? '?'; + } +}