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:
@@ -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
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/breaker/bloc/breaker_bloc.dart';
|
||||||
import 'state/app/modules/chat/bloc/chat_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/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/settings/bloc/settings_cubit.dart';
|
||||||
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
import 'storage/settings.dart';
|
import 'storage/settings.dart';
|
||||||
@@ -159,6 +160,7 @@ Future<void> main() async {
|
|||||||
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
|
||||||
),
|
),
|
||||||
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
|
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
|
||||||
|
BlocProvider<CommuteCubit>(create: (_) => CommuteCubit()),
|
||||||
],
|
],
|
||||||
child: const Main(),
|
child: const Main(),
|
||||||
),
|
),
|
||||||
@@ -252,6 +254,7 @@ class _MainState extends State<Main> {
|
|||||||
final chatListBloc = context.read<ChatListBloc>();
|
final chatListBloc = context.read<ChatListBloc>();
|
||||||
final chatBloc = context.read<ChatBloc>();
|
final chatBloc = context.read<ChatBloc>();
|
||||||
final breakerBloc = context.read<BreakerBloc>();
|
final breakerBloc = context.read<BreakerBloc>();
|
||||||
|
final commuteCubit = context.read<CommuteCubit>();
|
||||||
// Defer the actual wipe until after this frame so the
|
// Defer the actual wipe until after this frame so the
|
||||||
// App tree (TimetableBloc/ChatListBloc watchers etc.)
|
// App tree (TimetableBloc/ChatListBloc watchers etc.)
|
||||||
// is already torn down. Resetting blocs while App is
|
// is already torn down. Resetting blocs while App is
|
||||||
@@ -264,6 +267,7 @@ class _MainState extends State<Main> {
|
|||||||
chatListBloc: chatListBloc,
|
chatListBloc: chatListBloc,
|
||||||
chatBloc: chatBloc,
|
chatBloc: chatBloc,
|
||||||
breakerBloc: breakerBloc,
|
breakerBloc: breakerBloc,
|
||||||
|
commuteCubit: commuteCubit,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -308,6 +312,7 @@ Future<void> _wipeUserState({
|
|||||||
required ChatListBloc chatListBloc,
|
required ChatListBloc chatListBloc,
|
||||||
required ChatBloc chatBloc,
|
required ChatBloc chatBloc,
|
||||||
required BreakerBloc breakerBloc,
|
required BreakerBloc breakerBloc,
|
||||||
|
required CommuteCubit commuteCubit,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Reset user-data blocs whose tree is no longer mounted after the
|
// Reset user-data blocs whose tree is no longer mounted after the
|
||||||
@@ -320,6 +325,7 @@ Future<void> _wipeUserState({
|
|||||||
chatListBloc.reset(),
|
chatListBloc.reset(),
|
||||||
chatBloc.reset(),
|
chatBloc.reset(),
|
||||||
breakerBloc.reset(),
|
breakerBloc.reset(),
|
||||||
|
commuteCubit.reset(),
|
||||||
ConnectAuthStore.instance.clear(),
|
ConnectAuthStore.instance.clear(),
|
||||||
]);
|
]);
|
||||||
final prefs = await SharedPreferences.getInstance();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,43 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
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';
|
part 'timetable_settings.g.dart';
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable(explicitToJson: true)
|
||||||
class TimetableSettings {
|
class TimetableSettings {
|
||||||
bool connectDoubleLessons;
|
bool connectDoubleLessons;
|
||||||
TimetableNameMode timetableNameMode;
|
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({
|
TimetableSettings({
|
||||||
required this.connectDoubleLessons,
|
required this.connectDoubleLessons,
|
||||||
required this.timetableNameMode,
|
required this.timetableNameMode,
|
||||||
|
this.showCommuteInTimetable = false,
|
||||||
|
this.homeStation,
|
||||||
|
this.homeAddressLabel,
|
||||||
|
this.schoolStation,
|
||||||
|
this.commuteBufferMinutes = 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory TimetableSettings.fromJson(Map<String, dynamic> json) =>
|
factory TimetableSettings.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -6,20 +6,35 @@ part of 'timetable_settings.dart';
|
|||||||
// JsonSerializableGenerator
|
// JsonSerializableGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
TimetableSettings _$TimetableSettingsFromJson(Map<String, dynamic> json) =>
|
TimetableSettings _$TimetableSettingsFromJson(
|
||||||
TimetableSettings(
|
Map<String, dynamic> json,
|
||||||
connectDoubleLessons: json['connectDoubleLessons'] as bool,
|
) => TimetableSettings(
|
||||||
timetableNameMode: $enumDecode(
|
connectDoubleLessons: json['connectDoubleLessons'] as bool,
|
||||||
_$TimetableNameModeEnumMap,
|
timetableNameMode: $enumDecode(
|
||||||
json['timetableNameMode'],
|
_$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(
|
Map<String, dynamic> _$TimetableSettingsToJson(
|
||||||
TimetableSettings instance,
|
TimetableSettings instance,
|
||||||
) => <String, dynamic>{
|
) => <String, dynamic>{
|
||||||
'connectDoubleLessons': instance.connectDoubleLessons,
|
'connectDoubleLessons': instance.connectDoubleLessons,
|
||||||
'timetableNameMode': _$TimetableNameModeEnumMap[instance.timetableNameMode]!,
|
'timetableNameMode': _$TimetableNameModeEnumMap[instance.timetableNameMode]!,
|
||||||
|
'showCommuteInTimetable': instance.showCommuteInTimetable,
|
||||||
|
'homeStation': instance.homeStation?.toJson(),
|
||||||
|
'homeAddressLabel': instance.homeAddressLabel,
|
||||||
|
'schoolStation': instance.schoolStation?.toJson(),
|
||||||
|
'commuteBufferMinutes': instance.commuteBufferMinutes,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$TimetableNameModeEnumMap = {
|
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 '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
|
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
|
||||||
|
import 'commute_settings_section.dart';
|
||||||
|
|
||||||
class TimetableSection extends StatelessWidget {
|
class TimetableSection extends StatelessWidget {
|
||||||
const TimetableSection({super.key});
|
const TimetableSection({super.key});
|
||||||
@@ -54,6 +55,8 @@ class TimetableSection extends StatelessWidget {
|
|||||||
e!,
|
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/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||||
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
|
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
|
||||||
|
import 'commute_direction.dart';
|
||||||
|
|
||||||
sealed class ArbitraryAppointment {
|
sealed class ArbitraryAppointment {
|
||||||
const ArbitraryAppointment();
|
const ArbitraryAppointment();
|
||||||
@@ -7,9 +9,12 @@ sealed class ArbitraryAppointment {
|
|||||||
T when<T>({
|
T when<T>({
|
||||||
required T Function(GetTimetableResponseObject lesson) webuntis,
|
required T Function(GetTimetableResponseObject lesson) webuntis,
|
||||||
required T Function(CustomTimetableEvent event) custom,
|
required T Function(CustomTimetableEvent event) custom,
|
||||||
|
required T Function(Trip trip, CommuteDirection direction) commute,
|
||||||
}) => switch (this) {
|
}) => switch (this) {
|
||||||
WebuntisAppointment(:final lesson) => webuntis(lesson),
|
WebuntisAppointment(:final lesson) => webuntis(lesson),
|
||||||
CustomAppointment(:final event) => custom(event),
|
CustomAppointment(:final event) => custom(event),
|
||||||
|
CommuteAppointment(:final trip, :final direction) =>
|
||||||
|
commute(trip, direction),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,3 +27,9 @@ class CustomAppointment extends ArbitraryAppointment {
|
|||||||
final CustomTimetableEvent event;
|
final CustomTimetableEvent event;
|
||||||
const CustomAppointment(this.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) {
|
int _appointmentPriority(Appointment a) {
|
||||||
final id = a.id;
|
final id = a.id;
|
||||||
if (id is CustomAppointment) return 0;
|
if (id is CustomAppointment) return 0;
|
||||||
if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1;
|
if (id is CommuteAppointment) return 1;
|
||||||
return 2;
|
if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 2;
|
||||||
|
return 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assigns each appointment a lane index using a greedy sweep, then collapses
|
/// 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 '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
import '../data/arbitrary_appointment.dart';
|
import '../data/arbitrary_appointment.dart';
|
||||||
|
import '../widgets/commute/commute_details_sheet.dart';
|
||||||
import 'custom_event_sheet.dart';
|
import 'custom_event_sheet.dart';
|
||||||
import 'webuntis_lesson_sheet.dart';
|
import 'webuntis_lesson_sheet.dart';
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ class AppointmentDetailsDispatcher {
|
|||||||
webuntis: (lesson) =>
|
webuntis: (lesson) =>
|
||||||
WebuntisLessonSheet.show(context, bloc, appointment, lesson),
|
WebuntisLessonSheet.show(context, bloc, appointment, lesson),
|
||||||
custom: (event) => CustomEventSheet.show(context, event),
|
custom: (event) => CustomEventSheet.show(context, event),
|
||||||
|
commute: (trip, direction) =>
|
||||||
|
showCommuteDetailsSheet(context, trip: trip, direction: direction),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
|||||||
|
|
||||||
import '../../../routing/app_routes.dart';
|
import '../../../routing/app_routes.dart';
|
||||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.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/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
import '../../../storage/timetable_settings.dart';
|
import '../../../storage/timetable_settings.dart';
|
||||||
import 'custom_events/custom_event_edit_dialog.dart';
|
import 'custom_events/custom_event_edit_dialog.dart';
|
||||||
import 'data/arbitrary_appointment.dart';
|
import 'data/arbitrary_appointment.dart';
|
||||||
|
import 'data/commute_appointment_factory.dart';
|
||||||
import 'data/lesson_period_schedule.dart';
|
import 'data/lesson_period_schedule.dart';
|
||||||
import 'data/timetable_appointment_factory.dart';
|
import 'data/timetable_appointment_factory.dart';
|
||||||
import 'data/webuntis_time.dart';
|
import 'data/webuntis_time.dart';
|
||||||
@@ -33,6 +35,7 @@ class _TimetableState extends State<Timetable> {
|
|||||||
List<Appointment>? _cachedAppointments;
|
List<Appointment>? _cachedAppointments;
|
||||||
int? _lastDataVersion;
|
int? _lastDataVersion;
|
||||||
TimetableSettings? _lastTimetableSettings;
|
TimetableSettings? _lastTimetableSettings;
|
||||||
|
Map<String, CommuteDayEntry>? _lastCommuteState;
|
||||||
|
|
||||||
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
|
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
|
||||||
|
|
||||||
@@ -58,15 +61,23 @@ class _TimetableState extends State<Timetable> {
|
|||||||
.watch<SettingsCubit>()
|
.watch<SettingsCubit>()
|
||||||
.val()
|
.val()
|
||||||
.timetableSettings;
|
.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 &&
|
if (_cachedAppointments != null &&
|
||||||
_lastDataVersion == state.dataVersion &&
|
_lastDataVersion == state.dataVersion &&
|
||||||
identical(_lastTimetableSettings, timetableSettings)) {
|
identical(_lastTimetableSettings, timetableSettings) &&
|
||||||
|
identical(_lastCommuteState, commuteState)) {
|
||||||
return _cachedAppointments!;
|
return _cachedAppointments!;
|
||||||
}
|
}
|
||||||
_lastDataVersion = state.dataVersion;
|
_lastDataVersion = state.dataVersion;
|
||||||
_lastTimetableSettings = timetableSettings;
|
_lastTimetableSettings = timetableSettings;
|
||||||
|
_lastCommuteState = commuteState;
|
||||||
|
|
||||||
return _cachedAppointments = TimetableAppointmentFactory(
|
final base = TimetableAppointmentFactory(
|
||||||
lessons: state.getAllKnownLessons().toList(),
|
lessons: state.getAllKnownLessons().toList(),
|
||||||
customEvents: state.customEvents?.events ?? const [],
|
customEvents: state.customEvents?.events ?? const [],
|
||||||
rooms: state.rooms!,
|
rooms: state.rooms!,
|
||||||
@@ -74,11 +85,74 @@ class _TimetableState extends State<Timetable> {
|
|||||||
settings: timetableSettings,
|
settings: timetableSettings,
|
||||||
now: DateTime.now(),
|
now: DateTime.now(),
|
||||||
).build();
|
).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) {
|
bool _isCrossedOut(Appointment appointment) {
|
||||||
final id = appointment.id;
|
final id = appointment.id;
|
||||||
if (id is WebuntisAppointment) return id.lesson.code == 'cancelled';
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,3 +291,9 @@ class _TimetableState extends State<Timetable> {
|
|||||||
return (mondayMin, effectiveMax);
|
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/arbitrary_appointment.dart';
|
||||||
import '../data/calendar_layout.dart';
|
import '../data/calendar_layout.dart';
|
||||||
|
import 'commute/commute_tile_content.dart';
|
||||||
import 'cross_painter.dart';
|
import 'cross_painter.dart';
|
||||||
|
|
||||||
class AppointmentTile extends StatelessWidget {
|
class AppointmentTile extends StatelessWidget {
|
||||||
@@ -21,7 +22,9 @@ class AppointmentTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isPast = appointment.endTime.isBefore(DateTime.now());
|
final isPast = appointment.endTime.isBefore(DateTime.now());
|
||||||
final color = appointment.color.withAlpha(isPast ? 160 : 255);
|
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 ?? '';
|
final description = appointment.location ?? '';
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -37,11 +40,13 @@ class AppointmentTile extends StatelessWidget {
|
|||||||
borderRadius: _radius,
|
borderRadius: _radius,
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
child: _TileContent(
|
child: isCommute
|
||||||
title: appointment.subject,
|
? CommuteTileContent(commute: id, crossedOut: crossedOut)
|
||||||
description: description,
|
: _TileContent(
|
||||||
isCustom: isCustom,
|
title: appointment.subject,
|
||||||
),
|
description: description,
|
||||||
|
isCustom: isCustom,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (crossedOut)
|
if (crossedOut)
|
||||||
|
|||||||
@@ -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 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 = <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 ?? '?';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user