398 lines
12 KiB
Dart
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),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|