Files
Client/lib/view/pages/rmv/widgets/station_picker_sheet.dart
T

230 lines
6.8 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 '../../../../state/app/modules/rmv/repository/rmv_repository.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../utils/debouncer.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/centered_leading.dart';
import '../favorites_controller.dart';
/// Modal search sheet for picking a [StopLocation]. Shows favorites + recents
/// when the search field is empty, switches to live search results as soon as
/// the user types. Returns the chosen stop via [Navigator.pop], or `null` if
/// the user dismisses the sheet.
Future<StopLocation?> showStationPickerSheet(
BuildContext context, {
String title = 'Station auswählen',
}) => showModalBottomSheet<StopLocation>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
builder: (sheetCtx) => _StationPickerSheet(title: title),
);
class _StationPickerSheet extends StatefulWidget {
final String title;
const _StationPickerSheet({required this.title});
@override
State<_StationPickerSheet> createState() => _StationPickerSheetState();
}
class _StationPickerSheetState extends State<_StationPickerSheet> {
static const _debounceTag = 'rmv_station_search';
final TextEditingController _controller = TextEditingController();
final RmvRepository _repo = RmvRepository();
List<StopLocation>? _results;
bool _loading = false;
Object? _error;
String _query = '';
@override
void dispose() {
Debouncer.cancel(_debounceTag);
_controller.dispose();
super.dispose();
}
void _onChanged(String value) {
final trimmed = value.trim();
setState(() => _query = trimmed);
if (trimmed.length < 2) {
setState(() {
_results = null;
_error = null;
_loading = false;
});
Debouncer.cancel(_debounceTag);
return;
}
Debouncer.debounce(_debounceTag, const Duration(milliseconds: 300), () {
_runSearch(trimmed);
});
}
Future<void> _runSearch(String q) async {
setState(() {
_loading = true;
_error = null;
});
try {
final results = await _repo.searchStops(q, max: 25);
if (!mounted || _query != q) return;
setState(() {
_results = results;
_loading = false;
});
} catch (e) {
if (!mounted || _query != q) return;
setState(() {
_error = e;
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
final settings = context.read<SettingsCubit>();
final favorites =
settings.val().rmvSettings.favoriteStations;
final recents = settings.val().rmvSettings.recentStations;
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(
widget.title,
style: Theme.of(context).textTheme.titleLarge,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: _controller,
autofocus: true,
onChanged: _onChanged,
decoration: InputDecoration(
hintText: 'Station suchen…',
prefixIcon: const Icon(Icons.search),
suffixIcon: _query.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
_onChanged('');
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
minHeight: 200,
),
child: _body(favorites, recents),
),
],
),
);
}
Widget _body(List<StopLocation> favorites, List<StopLocation> recents) {
if (_loading) {
return const Center(child: AppProgressIndicator.medium());
}
final err = _error;
if (err != null) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(
errorToUserMessage(err),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
);
}
final results = _results;
if (results != null) {
if (results.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Keine Station für "$_query" gefunden.',
style: Theme.of(context).textTheme.bodyMedium,
),
),
);
}
return ListView(
children: results
.map((s) => _tile(s, leadingIcon: Icons.directions_transit))
.toList(),
);
}
// Empty query → favorites + recents.
final widgets = <Widget>[];
if (favorites.isNotEmpty) {
widgets.add(_sectionHeader('Favoriten'));
widgets.addAll(
favorites.map((s) => _tile(s, leadingIcon: Icons.star)),
);
}
if (recents.isNotEmpty) {
widgets.add(_sectionHeader('Zuletzt verwendet'));
widgets.addAll(
recents.map((s) => _tile(s, leadingIcon: Icons.history)),
);
}
if (widgets.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Tippe oben einen Stationsnamen ein, um die RMV-Datenbank zu durchsuchen.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
);
}
return ListView(children: widgets);
}
Widget _sectionHeader(String text) => Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
text,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.secondary,
),
),
);
Widget _tile(StopLocation stop, {required IconData leadingIcon}) => ListTile(
leading: CenteredLeading(Icon(leadingIcon)),
title: Text(stop.name),
subtitle: stop.description == null ? null : Text(stop.description!),
onTap: () {
RmvFavoritesController(context.read<SettingsCubit>()).addRecent(stop);
Navigator.of(context).pop(stop);
},
);
}