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
+16
View File
@@ -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<String, Object?> json) =>
_$NominatimResultFromJson(json);
}
@@ -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>(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<NominatimResult> get copyWith => _$NominatimResultCopyWithImpl<NominatimResult>(this as NominatimResult, _$identity);
/// Serializes this NominatimResult to a JSON map.
Map<String, dynamic> 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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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<String, dynamic> 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<String, dynamic> 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
+21
View File
@@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'nominatim_result.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_NominatimResult _$NominatimResultFromJson(Map<String, dynamic> json) =>
_NominatimResult(
displayName: json['displayName'] as String,
lat: (json['lat'] as num).toDouble(),
lon: (json['lon'] as num).toDouble(),
);
Map<String, dynamic> _$NominatimResultToJson(_NominatimResult instance) =>
<String, dynamic>{
'displayName': instance.displayName,
'lat': instance.lat,
'lon': instance.lon,
};
+72
View File
@@ -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<List<NominatimResult>> 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<String, dynamic>))
.toList(growable: false);
} catch (e) {
throw ParseException(technicalDetails: 'nominatim assemble: $e');
}
}
static NominatimResult _resultFromRaw(Map<String, dynamic> 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,
);
}
}
+6
View File
@@ -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<void> main() async {
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
),
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
BlocProvider<CommuteCubit>(create: (_) => CommuteCubit()),
],
child: const Main(),
),
@@ -252,6 +254,7 @@ class _MainState extends State<Main> {
final chatListBloc = context.read<ChatListBloc>();
final chatBloc = context.read<ChatBloc>();
final breakerBloc = context.read<BreakerBloc>();
final commuteCubit = context.read<CommuteCubit>();
// 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<Main> {
chatListBloc: chatListBloc,
chatBloc: chatBloc,
breakerBloc: breakerBloc,
commuteCubit: commuteCubit,
),
);
});
@@ -308,6 +312,7 @@ Future<void> _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<void> _wipeUserState({
chatListBloc.reset(),
chatBloc.reset(),
breakerBloc.reset(),
commuteCubit.reset(),
ConnectAuthStore.instance.clear(),
]);
final prefs = await SharedPreferences.getInstance();
@@ -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 {});
}
}
@@ -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<List<Trip>> 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<StopLocation?> 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;
}
}
+28 -2
View File
@@ -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<String, dynamic> json) =>
+17 -2
View File
@@ -6,13 +6,23 @@ part of 'timetable_settings.dart';
// JsonSerializableGenerator
// **************************************************************************
TimetableSettings _$TimetableSettingsFromJson(Map<String, dynamic> json) =>
TimetableSettings(
TimetableSettings _$TimetableSettingsFromJson(
Map<String, dynamic> 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<String, dynamic>),
homeAddressLabel: json['homeAddressLabel'] as String?,
schoolStation: json['schoolStation'] == null
? null
: StopLocation.fromJson(json['schoolStation'] as Map<String, dynamic>),
commuteBufferMinutes: (json['commuteBufferMinutes'] as num?)?.toInt() ?? 5,
);
Map<String, dynamic> _$TimetableSettingsToJson(
@@ -20,6 +30,11 @@ Map<String, dynamic> _$TimetableSettingsToJson(
) => <String, dynamic>{
'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 = {
@@ -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<SettingsCubit>();
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<void> _toggle(BuildContext context, bool value) async {
final settings = context.read<SettingsCubit>();
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<void> _editSchool(BuildContext context) async {
final picked = await showStationPickerSheet(
context,
title: 'Schul-Haltestelle wählen',
);
if (picked == null || !context.mounted) return;
context.read<SettingsCubit>().val(write: true).timetableSettings.schoolStation = picked;
}
Future<void> _editBuffer(BuildContext context) async {
final settings = context.read<SettingsCubit>();
final current = settings.val().timetableSettings.commuteBufferMinutes;
final picked = await showDialog<int>(
context: context,
builder: (_) => _BufferPickerDialog(initial: current),
);
if (picked == null) return;
settings.val(write: true).timetableSettings.commuteBufferMinutes = picked;
}
Future<void> _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<SettingsCubit>();
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<NominatimResult>? _addresses;
List<StopLocation>? _stops;
NominatimResult? _chosenAddress;
bool _loading = false;
Object? _error;
@override
void dispose() {
_queryCtrl.dispose();
super.dispose();
}
Future<void> _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<void> _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<NominatimResult> 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<StopLocation> 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),
);
},
);
}
}
@@ -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(),
],
);
}
@@ -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<T>({
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);
}
@@ -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
@@ -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<Appointment> build({
required List<Trip> morning,
required List<Trip> 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()}';
}
}
@@ -0,0 +1,2 @@
/// Direction of a commute trip relative to the school day.
enum CommuteDirection { toSchool, fromSchool }
@@ -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),
);
}
}
+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);
}
@@ -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,7 +40,9 @@ class AppointmentTile extends StatelessWidget {
borderRadius: _radius,
color: color,
),
child: _TileContent(
child: isCommute
? CommuteTileContent(commute: id, crossedOut: crossedOut)
: _TileContent(
title: appointment.subject,
description: description,
isCustom: isCustom,
@@ -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();
@@ -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 6080 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 = <Widget>[
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 ?? '?';
}
}