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,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 +40,13 @@ class AppointmentTile extends StatelessWidget {
|
||||
borderRadius: _radius,
|
||||
color: color,
|
||||
),
|
||||
child: _TileContent(
|
||||
title: appointment.subject,
|
||||
description: description,
|
||||
isCustom: isCustom,
|
||||
),
|
||||
child: isCommute
|
||||
? CommuteTileContent(commute: id, crossedOut: crossedOut)
|
||||
: _TileContent(
|
||||
title: appointment.subject,
|
||||
description: description,
|
||||
isCustom: isCustom,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (crossedOut)
|
||||
|
||||
@@ -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