Files
Client/lib/view/pages/settings/sections/commute_settings_section.dart
T

398 lines
12 KiB
Dart

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),
);
},
);
}
}