implemented RMV commute integration in the timetable, added Nominatim geocoding for home station lookup, created CommuteCubit for daily trip management with TTL caching, and introduced specialized timetable tiles, detail sheets, and settings for transit connections and walking buffers

This commit is contained in:
2026-05-20 22:50:57 +02:00
parent 067012cc84
commit 46d6b3410e
20 changed files with 1513 additions and 20 deletions
@@ -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),
);
},
);
}
}