implemented RMV public transit module including trip search, station departures, and nearby stop lookup, added "Marianum Connect" API integration with bearer token authentication and auto-refresh logic, integrated geolocator for location-based station search, added persistent storage for favorite stations and recent trip queries, and implemented comprehensive UI for journey details, trip results, and disruption alerts
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../state/app/modules/rmv/repository/rmv_repository.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
|
||||
class DisruptionsView extends StatefulWidget {
|
||||
const DisruptionsView({super.key});
|
||||
|
||||
@override
|
||||
State<DisruptionsView> createState() => _DisruptionsViewState();
|
||||
}
|
||||
|
||||
class _DisruptionsViewState extends State<DisruptionsView> {
|
||||
final RmvRepository _repo = RmvRepository();
|
||||
List<HimMessage>? _items;
|
||||
bool _loading = true;
|
||||
Object? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final r = await _repo.disruptions();
|
||||
if (!mounted) return;
|
||||
r.sort((a, b) => (b.priority ?? 0).compareTo(a.priority ?? 0));
|
||||
setState(() {
|
||||
_items = r;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Störungsmeldungen')),
|
||||
body: _body(),
|
||||
);
|
||||
|
||||
Widget _body() {
|
||||
if (_loading) return const Center(child: AppProgressIndicator.large());
|
||||
final err = _error;
|
||||
if (err != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
errorToUserMessage(err),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _load,
|
||||
label: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final list = _items ?? const <HimMessage>[];
|
||||
if (list.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 80, horizontal: 24),
|
||||
child: Center(child: Text('Keine aktiven Meldungen.')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
itemCount: list.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) => _tile(list[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tile(HimMessage msg) => ListTile(
|
||||
leading: Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
color: _priorityColor(msg.priority),
|
||||
),
|
||||
title: Text(
|
||||
msg.head ?? msg.lead ?? msg.text ?? 'Meldung',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: msg.lead == null
|
||||
? null
|
||||
: Text(msg.lead!, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showDetails(msg),
|
||||
);
|
||||
|
||||
Color _priorityColor(int? priority) {
|
||||
if (priority == null) return Colors.orange;
|
||||
if (priority >= 100) return Colors.red;
|
||||
if (priority >= 50) return Colors.orange;
|
||||
return Colors.amber;
|
||||
}
|
||||
|
||||
void _showDetails(HimMessage msg) {
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
header: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||
child: Text(
|
||||
msg.head ?? 'Störungsmeldung',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
children: (ctx) => [
|
||||
if (msg.lead != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.short_text),
|
||||
title: Text(msg.lead!),
|
||||
),
|
||||
if (msg.text != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.notes),
|
||||
title: Text(msg.text!),
|
||||
),
|
||||
if (msg.category != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.category_outlined),
|
||||
title: Text(msg.category!),
|
||||
),
|
||||
if (msg.company != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.business_outlined),
|
||||
title: Text(msg.company!),
|
||||
),
|
||||
if (msg.startValidity != null || msg.endValidity != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.event_outlined),
|
||||
title: Text(_validityRange(msg)),
|
||||
),
|
||||
if (msg.modified != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.update_outlined),
|
||||
title: Text('Aktualisiert: ${msg.modified!.formatDateTime()}'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _validityRange(HimMessage msg) {
|
||||
final start = msg.startValidity?.formatDateTime();
|
||||
final end = msg.endValidity?.formatDateTime();
|
||||
if (start != null && end != null) return '$start – $end';
|
||||
return start ?? end ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import '../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../storage/rmv_settings.dart';
|
||||
|
||||
/// Thin wrapper around the global [SettingsCubit] that keeps the RMV
|
||||
/// favorites/recents bookkeeping out of every view. All mutations go through
|
||||
/// here so that the cubit's write/emit cycle and the recent-list trimming
|
||||
/// stay consistent.
|
||||
class RmvFavoritesController {
|
||||
final SettingsCubit _settings;
|
||||
|
||||
RmvFavoritesController(this._settings);
|
||||
|
||||
RmvSettings get _rmv => _settings.val().rmvSettings;
|
||||
|
||||
bool isFavorite(StopLocation stop) =>
|
||||
_rmv.favoriteStations.any((s) => s.id == stop.id);
|
||||
|
||||
void toggleFavorite(StopLocation stop) {
|
||||
if (isFavorite(stop)) {
|
||||
removeFavorite(stop);
|
||||
} else {
|
||||
addFavorite(stop);
|
||||
}
|
||||
}
|
||||
|
||||
void addFavorite(StopLocation stop) {
|
||||
final mutable = _settings.val(write: true).rmvSettings;
|
||||
if (mutable.favoriteStations.any((s) => s.id == stop.id)) return;
|
||||
mutable.favoriteStations = [...mutable.favoriteStations, stop];
|
||||
}
|
||||
|
||||
void removeFavorite(StopLocation stop) {
|
||||
final mutable = _settings.val(write: true).rmvSettings;
|
||||
mutable.favoriteStations = mutable.favoriteStations
|
||||
.where((s) => s.id != stop.id)
|
||||
.toList();
|
||||
}
|
||||
|
||||
void addRecent(StopLocation stop) {
|
||||
final mutable = _settings.val(write: true).rmvSettings;
|
||||
final filtered =
|
||||
mutable.recentStations.where((s) => s.id != stop.id).toList();
|
||||
filtered.insert(0, stop);
|
||||
if (filtered.length > RmvSettings.maxRecents) {
|
||||
filtered.removeRange(RmvSettings.maxRecents, filtered.length);
|
||||
}
|
||||
mutable.recentStations = filtered;
|
||||
}
|
||||
|
||||
void clearRecents() {
|
||||
_settings.val(write: true).rmvSettings.recentStations = const [];
|
||||
}
|
||||
|
||||
void addRecentTrip(StopLocation from, StopLocation to) {
|
||||
final mutable = _settings.val(write: true).rmvSettings;
|
||||
final filtered = mutable.recentTripQueries
|
||||
.where((q) => q.from.id != from.id || q.to.id != to.id)
|
||||
.toList();
|
||||
filtered.insert(
|
||||
0,
|
||||
RecentTripQuery(
|
||||
from: from,
|
||||
to: to,
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
if (filtered.length > RmvSettings.maxRecents) {
|
||||
filtered.removeRange(RmvSettings.maxRecents, filtered.length);
|
||||
}
|
||||
mutable.recentTripQueries = filtered;
|
||||
}
|
||||
|
||||
void clearRecentTrips() {
|
||||
_settings.val(write: true).rmvSettings.recentTripQueries = const [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../state/app/modules/rmv/repository/rmv_repository.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
import '../widgets/product_chip.dart';
|
||||
import '../widgets/realtime_time.dart';
|
||||
|
||||
class JourneyDetailView extends StatefulWidget {
|
||||
final String journeyRef;
|
||||
final DateTime? date;
|
||||
|
||||
const JourneyDetailView({super.key, required this.journeyRef, this.date});
|
||||
|
||||
@override
|
||||
State<JourneyDetailView> createState() => _JourneyDetailViewState();
|
||||
}
|
||||
|
||||
class _JourneyDetailViewState extends State<JourneyDetailView> {
|
||||
final RmvRepository _repo = RmvRepository();
|
||||
JourneyDetail? _detail;
|
||||
bool _loading = true;
|
||||
Object? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final detail = await _repo.journeyDetail(
|
||||
widget.journeyRef,
|
||||
date: widget.date,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_detail = detail;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final detail = _detail;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
ProductChip(product: detail?.product),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
detail?.direction ?? 'Fahrt',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: _body(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body() {
|
||||
if (_loading) {
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
final err = _error;
|
||||
if (err != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
errorToUserMessage(err),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final stops = _detail?.stops ?? const <JourneyStop>[];
|
||||
if (stops.isEmpty) {
|
||||
return const Center(child: Text('Keine Halte verfügbar.'));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
itemCount: stops.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) => _stopTile(stops[i], i, stops.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stopTile(JourneyStop stop, int idx, int total) {
|
||||
final isFirst = idx == 0;
|
||||
final isLast = idx == total - 1;
|
||||
final arrival = stop.realArrival ?? stop.scheduledArrival;
|
||||
final departure = stop.realDeparture ?? stop.scheduledDeparture;
|
||||
final track = (stop.realDepTrack?.isNotEmpty ?? false)
|
||||
? stop.realDepTrack
|
||||
: stop.depTrack;
|
||||
return ListTile(
|
||||
leading: SizedBox(
|
||||
width: 24,
|
||||
child: Icon(
|
||||
isFirst
|
||||
? Icons.trip_origin
|
||||
: (isLast ? Icons.place : Icons.fiber_manual_record),
|
||||
size: isFirst || isLast ? 20 : 10,
|
||||
color: stop.cancelled
|
||||
? Colors.red
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
stop.name,
|
||||
style: TextStyle(
|
||||
decoration: stop.cancelled ? TextDecoration.lineThrough : null,
|
||||
fontWeight: (isFirst || isLast) ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
subtitle: (track == null || track.isEmpty)
|
||||
? null
|
||||
: Text('Gleis $track'),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (stop.scheduledArrival != null)
|
||||
RealtimeTime(
|
||||
scheduled: stop.scheduledArrival!,
|
||||
realtime: stop.realArrival,
|
||||
cancelled: stop.cancelledArrival,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
if (stop.scheduledDeparture != null &&
|
||||
stop.scheduledDeparture != stop.scheduledArrival)
|
||||
RealtimeTime(
|
||||
scheduled: stop.scheduledDeparture!,
|
||||
realtime: stop.realDeparture,
|
||||
cancelled: stop.cancelledDeparture,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
if (arrival == null && departure == null) const Text('-'),
|
||||
],
|
||||
),
|
||||
isThreeLine: arrival != null && departure != null && arrival != departure,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows showing "departure 14:35" as a tooltip in the journey timeline.
|
||||
String formatStopMoment(DateTime t) => t.formatHm();
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
|
||||
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
||||
import '../../../state/app/modules/rmv/bloc/rmv_bloc.dart';
|
||||
import '../../../state/app/modules/rmv/bloc/rmv_state.dart';
|
||||
import 'stations/station_overview_tab.dart';
|
||||
import 'trip_search/trip_search_tab.dart';
|
||||
|
||||
class RmvView extends StatelessWidget {
|
||||
const RmvView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocModule<RmvBloc, LoadableState<RmvState>>(
|
||||
create: (context) => RmvBloc(),
|
||||
autoRebuild: true,
|
||||
child: (context, bloc, state) {
|
||||
final disruptions = bloc.getDisruptions();
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('RMV-Fahrplan'),
|
||||
bottom: const TabBar(
|
||||
tabs: [
|
||||
Tab(icon: Icon(Icons.alt_route), text: 'Verbindung'),
|
||||
Tab(icon: Icon(Icons.directions_bus), text: 'Stationen'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Builder(
|
||||
builder: (ctx) => IconButton(
|
||||
icon: _disruptionsIcon(disruptions.length),
|
||||
tooltip: 'Störungsmeldungen',
|
||||
onPressed: () => AppRoutes.openRmvDisruptions(ctx),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: const TabBarView(
|
||||
children: [
|
||||
TripSearchTab(),
|
||||
StationOverviewTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _disruptionsIcon(int count) {
|
||||
if (count <= 0) return const Icon(Icons.warning_amber_outlined);
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_outlined),
|
||||
Positioned(
|
||||
right: -6,
|
||||
top: -6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 16, minHeight: 16),
|
||||
child: Text(
|
||||
count > 99 ? '99+' : '$count',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/rmv/repository/rmv_repository.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
|
||||
class NearbyStationsView extends StatefulWidget {
|
||||
const NearbyStationsView({super.key});
|
||||
|
||||
@override
|
||||
State<NearbyStationsView> createState() => _NearbyStationsViewState();
|
||||
}
|
||||
|
||||
class _NearbyStationsViewState extends State<NearbyStationsView> {
|
||||
final RmvRepository _repo = RmvRepository();
|
||||
List<StopLocation>? _stops;
|
||||
bool _loading = true;
|
||||
String? _userError;
|
||||
Object? _apiError;
|
||||
int _radiusMeters = 1000;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_userError = null;
|
||||
_apiError = null;
|
||||
});
|
||||
final position = await _resolvePosition();
|
||||
if (!mounted) return;
|
||||
if (position == null) {
|
||||
// _userError is set by _resolvePosition
|
||||
setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final stops = await _repo.nearbyStops(
|
||||
lat: position.latitude,
|
||||
lon: position.longitude,
|
||||
radiusMeters: _radiusMeters,
|
||||
max: 30,
|
||||
);
|
||||
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(() {
|
||||
_apiError = e;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<Position?> _resolvePosition() async {
|
||||
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||
_userError =
|
||||
'Bitte aktiviere die Standortdienste in den System-Einstellungen.';
|
||||
return null;
|
||||
}
|
||||
var permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
_userError =
|
||||
'Standortzugriff dauerhaft verweigert. Bitte in den App-Einstellungen aktivieren.';
|
||||
return null;
|
||||
}
|
||||
if (permission == LocationPermission.denied) {
|
||||
_userError = 'Ohne Standortzugriff können keine Stationen in der Nähe gefunden werden.';
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.medium,
|
||||
timeLimit: Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
_userError = 'Standort konnte nicht ermittelt werden: $e';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('In meiner Nähe'),
|
||||
actions: [
|
||||
PopupMenuButton<int>(
|
||||
tooltip: 'Suchradius',
|
||||
icon: const Icon(Icons.tune),
|
||||
onSelected: (r) {
|
||||
setState(() => _radiusMeters = r);
|
||||
_load();
|
||||
},
|
||||
itemBuilder: (_) => [500, 1000, 2000, 5000]
|
||||
.map(
|
||||
(r) => CheckedPopupMenuItem<int>(
|
||||
value: r,
|
||||
checked: r == _radiusMeters,
|
||||
child: Text(r >= 1000 ? '${r ~/ 1000} km' : '$r m'),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _body(),
|
||||
);
|
||||
|
||||
Widget _body() {
|
||||
if (_loading) return const Center(child: AppProgressIndicator.large());
|
||||
if (_userError != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_userError!, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _load,
|
||||
label: const Text('Erneut versuchen'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: Geolocator.openAppSettings,
|
||||
label: const Text('Einstellungen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_apiError != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
errorToUserMessage(_apiError),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _load,
|
||||
label: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final list = _stops ?? const <StopLocation>[];
|
||||
if (list.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Keine Stationen im gewählten Umkreis gefunden.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
itemCount: list.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) => _tile(list[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tile(StopLocation stop) => ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.directions_transit)),
|
||||
title: Text(stop.name),
|
||||
subtitle: stop.distanceMeters == null
|
||||
? null
|
||||
: Text('${stop.distanceMeters} m entfernt'),
|
||||
onTap: () => AppRoutes.openRmvStationDetail(context, stop),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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 '../../../../routing/app_routes.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 '../favorites_controller.dart';
|
||||
import '../widgets/departure_arrival_tile.dart';
|
||||
|
||||
enum _Direction { departures, arrivals }
|
||||
|
||||
class StationDetailView extends StatefulWidget {
|
||||
final StopLocation station;
|
||||
const StationDetailView({super.key, required this.station});
|
||||
|
||||
@override
|
||||
State<StationDetailView> createState() => _StationDetailViewState();
|
||||
}
|
||||
|
||||
class _StationDetailViewState extends State<StationDetailView> {
|
||||
final RmvRepository _repo = RmvRepository();
|
||||
_Direction _direction = _Direction.departures;
|
||||
List<Departure>? _departures;
|
||||
List<Arrival>? _arrivals;
|
||||
bool _loading = false;
|
||||
Object? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
if (_direction == _Direction.departures) {
|
||||
final result = await _repo.departures(widget.station.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_departures = result;
|
||||
_loading = false;
|
||||
});
|
||||
} else {
|
||||
final result = await _repo.arrivals(widget.station.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_arrivals = result;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _switch(_Direction d) {
|
||||
if (d == _direction) return;
|
||||
setState(() {
|
||||
_direction = d;
|
||||
_departures = null;
|
||||
_arrivals = null;
|
||||
});
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsCubit>();
|
||||
final favCtrl = RmvFavoritesController(settings);
|
||||
final isFav = favCtrl.isFavorite(widget.station);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.station.name),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: isFav ? 'Favorit entfernen' : 'Als Favorit speichern',
|
||||
icon: Icon(isFav ? Icons.star : Icons.star_border),
|
||||
onPressed: () => favCtrl.toggleFavorite(widget.station),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: SegmentedButton<_Direction>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: _Direction.departures,
|
||||
icon: Icon(Icons.north_east),
|
||||
label: Text('Abfahrten'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _Direction.arrivals,
|
||||
icon: Icon(Icons.south_west),
|
||||
label: Text('Ankünfte'),
|
||||
),
|
||||
],
|
||||
selected: {_direction},
|
||||
onSelectionChanged: (s) => _switch(s.first),
|
||||
),
|
||||
),
|
||||
Expanded(child: _body()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body() {
|
||||
if (_loading) {
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
final err = _error;
|
||||
if (err != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
errorToUserMessage(err),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _load,
|
||||
label: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_direction == _Direction.departures) {
|
||||
final list = _departures ?? const <Departure>[];
|
||||
if (list.isEmpty) return _emptyState('Keine Abfahrten gefunden.');
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
itemCount: list.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) => DepartureArrivalTile.fromDeparture(
|
||||
list[i],
|
||||
onTap: () => _openJourney(list[i].journeyRef),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final list = _arrivals ?? const <Arrival>[];
|
||||
if (list.isEmpty) return _emptyState('Keine Ankünfte gefunden.');
|
||||
return RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
itemCount: list.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) => DepartureArrivalTile.fromArrival(
|
||||
list[i],
|
||||
onTap: () => _openJourney(list[i].journeyRef),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyState(String text) => RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 80, horizontal: 24),
|
||||
child: Center(
|
||||
child: Text(text, style: Theme.of(context).textTheme.bodyLarge),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
void _openJourney(String? ref) {
|
||||
if (ref == null) return;
|
||||
AppRoutes.openRmvJourneyDetail(context, ref);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../storage/settings.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../favorites_controller.dart';
|
||||
import '../widgets/station_picker_sheet.dart';
|
||||
import 'nearby_stations_view.dart';
|
||||
|
||||
class StationOverviewTab extends StatelessWidget {
|
||||
const StationOverviewTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocBuilder<SettingsCubit, Settings>(builder: _buildBody);
|
||||
|
||||
Widget _buildBody(BuildContext context, Settings settings) {
|
||||
final rmv = settings.rmvSettings;
|
||||
final favorites = rmv.favoriteStations;
|
||||
final recents = rmv.recentStations;
|
||||
final favCtrl = RmvFavoritesController(context.read<SettingsCubit>());
|
||||
|
||||
final children = <Widget>[
|
||||
_searchBar(context),
|
||||
_nearbyButton(context),
|
||||
if (favorites.isEmpty && recents.isEmpty) _emptyState(context),
|
||||
if (favorites.isNotEmpty) ...[
|
||||
_sectionHeader(context, 'Favoriten', null),
|
||||
...favorites.map((s) => _stationTile(context, s, favCtrl, isFavorite: true)),
|
||||
],
|
||||
if (recents.isNotEmpty) ...[
|
||||
_sectionHeader(
|
||||
context,
|
||||
'Zuletzt verwendet',
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_sweep_outlined),
|
||||
tooltip: 'Alle löschen',
|
||||
onPressed: () => _confirmClearRecents(context, favCtrl),
|
||||
),
|
||||
),
|
||||
...recents.map((s) => _stationTile(context, s, favCtrl, isFavorite: false)),
|
||||
],
|
||||
];
|
||||
|
||||
return ListView(children: children);
|
||||
}
|
||||
|
||||
Widget _searchBar(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: FilledButton.tonalIcon(
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Station suchen…'),
|
||||
onPressed: () async {
|
||||
final picked = await showStationPickerSheet(context);
|
||||
if (picked != null && context.mounted) {
|
||||
AppRoutes.openRmvStationDetail(context, picked);
|
||||
}
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _nearbyButton(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.my_location),
|
||||
label: const Text('In meiner Nähe'),
|
||||
onPressed: () => Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => const NearbyStationsView()),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(40),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _sectionHeader(BuildContext context, String title, Widget? trailing) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _emptyState(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 40, 24, 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Noch keine Stationen gespeichert. Suche eine Station, um sie zu öffnen oder als Favorit zu markieren.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _stationTile(
|
||||
BuildContext context,
|
||||
StopLocation station,
|
||||
RmvFavoritesController favCtrl, {
|
||||
required bool isFavorite,
|
||||
}) => ListTile(
|
||||
leading: CenteredLeading(
|
||||
Icon(isFavorite ? Icons.star : Icons.directions_transit),
|
||||
),
|
||||
title: Text(station.name),
|
||||
subtitle: station.description == null ? null : Text(station.description!),
|
||||
trailing: IconButton(
|
||||
icon: Icon(
|
||||
favCtrl.isFavorite(station) ? Icons.star : Icons.star_border,
|
||||
),
|
||||
tooltip: favCtrl.isFavorite(station)
|
||||
? 'Favorit entfernen'
|
||||
: 'Als Favorit speichern',
|
||||
onPressed: () => favCtrl.toggleFavorite(station),
|
||||
),
|
||||
onTap: () => AppRoutes.openRmvStationDetail(context, station),
|
||||
);
|
||||
|
||||
Future<void> _confirmClearRecents(
|
||||
BuildContext context,
|
||||
RmvFavoritesController favCtrl,
|
||||
) async {
|
||||
ConfirmDialog(
|
||||
title: 'Verlauf leeren?',
|
||||
content:
|
||||
'Die zuletzt verwendeten Stationen werden aus der Übersicht entfernt. Favoriten bleiben bestehen.',
|
||||
confirmButton: 'Leeren',
|
||||
onConfirm: () => favCtrl.clearRecents(),
|
||||
).asDialog(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../widgets/leg_tile.dart';
|
||||
import '../widgets/trip_tile.dart';
|
||||
|
||||
class TripDetailView extends StatelessWidget {
|
||||
final Trip trip;
|
||||
|
||||
const TripDetailView({super.key, required this.trip});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final first = trip.legs.isEmpty ? null : trip.legs.first;
|
||||
final last = trip.legs.isEmpty ? null : trip.legs.last;
|
||||
final duration = trip.realDuration ?? trip.duration;
|
||||
final transfers =
|
||||
trip.transferCount ?? _journeyLegs(trip).length.clamp(1, 99) - 1;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: first == null
|
||||
? const Text('Verbindung')
|
||||
: Text('${first.origin.name} → ${last!.destination.name}'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: _summary(context, duration, transfers, first, last),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
...trip.legs.map(
|
||||
(l) => LegTile(
|
||||
leg: l,
|
||||
onShowJourneyDetail: l.journeyRef == null
|
||||
? null
|
||||
: () => AppRoutes.openRmvJourneyDetail(
|
||||
context,
|
||||
l.journeyRef!,
|
||||
date: l.origin.scheduledTime,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _summary(
|
||||
BuildContext context,
|
||||
Duration? duration,
|
||||
int transfers,
|
||||
Leg? first,
|
||||
Leg? last,
|
||||
) {
|
||||
if (first == null || last == null) return const SizedBox.shrink();
|
||||
return Row(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
first.origin.scheduledTime.formatDateRelativeShort(),
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${first.origin.scheduledTime.formatHm()} – ${last.destination.scheduledTime.formatHm()}',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (duration != null)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.schedule, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(formatTripDuration(duration)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.swap_horiz, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
transfers == 0
|
||||
? 'Direkt'
|
||||
: '$transfers Umstieg${transfers > 1 ? 'e' : ''}',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Leg> _journeyLegs(Trip t) =>
|
||||
t.legs.where((l) => l.type == LegType.journey);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/rmv/repository/rmv_repository.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
import '../widgets/trip_tile.dart';
|
||||
|
||||
class TripResultsView extends StatefulWidget {
|
||||
final StopLocation from;
|
||||
final StopLocation to;
|
||||
final DateTime? when;
|
||||
final bool byArrival;
|
||||
|
||||
const TripResultsView({
|
||||
super.key,
|
||||
required this.from,
|
||||
required this.to,
|
||||
this.when,
|
||||
this.byArrival = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TripResultsView> createState() => _TripResultsViewState();
|
||||
}
|
||||
|
||||
class _TripResultsViewState extends State<TripResultsView> {
|
||||
final RmvRepository _repo = RmvRepository();
|
||||
final List<Trip> _trips = [];
|
||||
String? _scrollLater;
|
||||
String? _scrollEarlier;
|
||||
bool _loading = true;
|
||||
bool _loadingMore = false;
|
||||
Object? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initial();
|
||||
}
|
||||
|
||||
Future<void> _initial() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_trips.clear();
|
||||
_scrollEarlier = null;
|
||||
_scrollLater = null;
|
||||
});
|
||||
try {
|
||||
final r = await _repo.searchTrips(
|
||||
fromStopId: widget.from.id,
|
||||
toStopId: widget.to.id,
|
||||
when: widget.when,
|
||||
searchByArrival: widget.byArrival,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_trips.addAll(r.trips);
|
||||
_scrollEarlier = r.scrollContextEarlier;
|
||||
_scrollLater = r.scrollContextLater;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore({required bool later}) async {
|
||||
final ctx = later ? _scrollLater : _scrollEarlier;
|
||||
if (ctx == null || _loadingMore) return;
|
||||
setState(() => _loadingMore = true);
|
||||
try {
|
||||
final r = await _repo.moreTrips(ctx);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (later) {
|
||||
_trips.addAll(r.trips);
|
||||
} else {
|
||||
_trips.insertAll(0, r.trips);
|
||||
}
|
||||
if (r.scrollContextEarlier != null) {
|
||||
_scrollEarlier = r.scrollContextEarlier;
|
||||
}
|
||||
if (r.scrollContextLater != null) {
|
||||
_scrollLater = r.scrollContextLater;
|
||||
}
|
||||
_loadingMore = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingMore = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(errorToUserMessage(e))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final whenLabel = widget.when == null
|
||||
? 'jetzt'
|
||||
: '${widget.byArrival ? 'an' : 'ab'} ${widget.when!.formatDateTime()}';
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('${widget.from.name} → ${widget.to.name}'),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
whenLabel,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: _body(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body() {
|
||||
if (_loading) return const Center(child: AppProgressIndicator.large());
|
||||
final err = _error;
|
||||
if (err != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
errorToUserMessage(err),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _initial,
|
||||
label: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_trips.isEmpty) {
|
||||
return const Center(child: Text('Keine Verbindungen gefunden.'));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _initial,
|
||||
child: ListView.separated(
|
||||
itemCount: _trips.length + 2,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) {
|
||||
if (i == 0) {
|
||||
return _scrollButton(
|
||||
icon: Icons.arrow_upward,
|
||||
label: 'Frühere Verbindungen',
|
||||
enabled: _scrollEarlier != null,
|
||||
onTap: () => _loadMore(later: false),
|
||||
);
|
||||
}
|
||||
if (i == _trips.length + 1) {
|
||||
return _scrollButton(
|
||||
icon: Icons.arrow_downward,
|
||||
label: 'Spätere Verbindungen',
|
||||
enabled: _scrollLater != null,
|
||||
onTap: () => _loadMore(later: true),
|
||||
);
|
||||
}
|
||||
final trip = _trips[i - 1];
|
||||
return TripTile(
|
||||
trip: trip,
|
||||
onTap: () => AppRoutes.openRmvTripDetail(context, trip),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _scrollButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required bool enabled,
|
||||
required VoidCallback onTap,
|
||||
}) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: OutlinedButton.icon(
|
||||
icon: _loadingMore
|
||||
? const AppProgressIndicator.small()
|
||||
: Icon(icon),
|
||||
label: Text(label),
|
||||
onPressed: enabled && !_loadingMore ? onTap : null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(40),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../storage/rmv_settings.dart';
|
||||
import '../../../../storage/settings.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../favorites_controller.dart';
|
||||
import '../widgets/station_picker_sheet.dart';
|
||||
import '../widgets/when_picker.dart';
|
||||
|
||||
class TripSearchTab extends StatefulWidget {
|
||||
const TripSearchTab({super.key});
|
||||
|
||||
@override
|
||||
State<TripSearchTab> createState() => _TripSearchTabState();
|
||||
}
|
||||
|
||||
class _TripSearchTabState extends State<TripSearchTab> {
|
||||
StopLocation? _from;
|
||||
StopLocation? _to;
|
||||
DateTime? _when;
|
||||
bool _byArrival = false;
|
||||
|
||||
Future<void> _pickFrom() async {
|
||||
final s = await showStationPickerSheet(
|
||||
context,
|
||||
title: 'Von welcher Station?',
|
||||
);
|
||||
if (s != null) setState(() => _from = s);
|
||||
}
|
||||
|
||||
Future<void> _pickTo() async {
|
||||
final s = await showStationPickerSheet(
|
||||
context,
|
||||
title: 'Wohin?',
|
||||
);
|
||||
if (s != null) setState(() => _to = s);
|
||||
}
|
||||
|
||||
void _swap() {
|
||||
setState(() {
|
||||
final tmp = _from;
|
||||
_from = _to;
|
||||
_to = tmp;
|
||||
});
|
||||
}
|
||||
|
||||
void _search(BuildContext context) {
|
||||
final from = _from;
|
||||
final to = _to;
|
||||
if (from == null || to == null) return;
|
||||
RmvFavoritesController(context.read<SettingsCubit>())
|
||||
.addRecentTrip(from, to);
|
||||
AppRoutes.openRmvTripResults(
|
||||
context,
|
||||
from: from,
|
||||
to: to,
|
||||
when: _when,
|
||||
byArrival: _byArrival,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocBuilder<SettingsCubit, Settings>(builder: _buildContent);
|
||||
|
||||
Widget _buildContent(BuildContext context, Settings settings) {
|
||||
final canSearch = _from != null && _to != null;
|
||||
final recents = settings.rmvSettings.recentTripQueries;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_stationField(
|
||||
label: 'Von',
|
||||
icon: Icons.trip_origin,
|
||||
value: _from,
|
||||
onTap: _pickFrom,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _stationField(
|
||||
label: 'Nach',
|
||||
icon: Icons.place,
|
||||
value: _to,
|
||||
onTap: _pickTo,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.swap_vert),
|
||||
tooltip: 'Start und Ziel tauschen',
|
||||
onPressed: _from == null && _to == null ? null : _swap,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
WhenPicker(
|
||||
value: _when,
|
||||
byArrival: _byArrival,
|
||||
onValueChanged: (v) => setState(() => _when = v),
|
||||
onByArrivalChanged: (v) => setState(() => _byArrival = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Verbindungen suchen'),
|
||||
onPressed: canSearch ? () => _search(context) : null,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
if (recents.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Noch keine Suchen. Wähle Start und Ziel, um die erste Verbindung zu suchen.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
_recentsSection(context, recents),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stationField({
|
||||
required String label,
|
||||
required IconData icon,
|
||||
required StopLocation? value,
|
||||
required VoidCallback onTap,
|
||||
}) => InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixIcon: Icon(icon),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: const Icon(Icons.arrow_drop_down),
|
||||
),
|
||||
child: Text(
|
||||
value?.name ?? 'Station wählen',
|
||||
style: value == null
|
||||
? TextStyle(color: Theme.of(context).hintColor)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _recentsSection(
|
||||
BuildContext context,
|
||||
List<RecentTripQuery> recents,
|
||||
) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Letzte Suchen',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_sweep_outlined),
|
||||
tooltip: 'Alle löschen',
|
||||
onPressed: () => ConfirmDialog(
|
||||
title: 'Suchverlauf leeren?',
|
||||
content:
|
||||
'Die letzten Verbindungssuchen werden entfernt. Favoriten bleiben bestehen.',
|
||||
confirmButton: 'Leeren',
|
||||
onConfirm: () => RmvFavoritesController(
|
||||
context.read<SettingsCubit>(),
|
||||
).clearRecentTrips(),
|
||||
).asDialog(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...recents.map(
|
||||
(q) => ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.history)),
|
||||
title: Text('${q.from.name} → ${q.to.name}'),
|
||||
onTap: () => setState(() {
|
||||
_from = q.from;
|
||||
_to = q.to;
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import 'product_chip.dart';
|
||||
import 'realtime_time.dart';
|
||||
|
||||
/// Renders a single departure or arrival row. Used in the station detail view.
|
||||
class DepartureArrivalTile extends StatelessWidget {
|
||||
final Product? product;
|
||||
final String name;
|
||||
|
||||
/// Direction (for departures) or origin (for arrivals).
|
||||
final String towards;
|
||||
final DateTime scheduled;
|
||||
final DateTime? realtime;
|
||||
final int? delayMinutes;
|
||||
final String? track;
|
||||
final String? realTrack;
|
||||
final bool cancelled;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const DepartureArrivalTile({
|
||||
super.key,
|
||||
required this.product,
|
||||
required this.name,
|
||||
required this.towards,
|
||||
required this.scheduled,
|
||||
this.realtime,
|
||||
this.delayMinutes,
|
||||
this.track,
|
||||
this.realTrack,
|
||||
this.cancelled = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
factory DepartureArrivalTile.fromDeparture(
|
||||
Departure d, {
|
||||
VoidCallback? onTap,
|
||||
}) => DepartureArrivalTile(
|
||||
product: d.product,
|
||||
name: d.name,
|
||||
towards: 'nach ${d.direction}',
|
||||
scheduled: d.scheduledTime,
|
||||
realtime: d.realTime,
|
||||
delayMinutes: d.delayMinutes,
|
||||
track: d.track,
|
||||
realTrack: d.realTrack,
|
||||
cancelled: d.cancelled,
|
||||
onTap: onTap,
|
||||
);
|
||||
|
||||
factory DepartureArrivalTile.fromArrival(
|
||||
Arrival a, {
|
||||
VoidCallback? onTap,
|
||||
}) => DepartureArrivalTile(
|
||||
product: a.product,
|
||||
name: a.name,
|
||||
towards: 'von ${a.origin}',
|
||||
scheduled: a.scheduledTime,
|
||||
realtime: a.realTime,
|
||||
delayMinutes: a.delayMinutes,
|
||||
track: a.track,
|
||||
realTrack: a.realTrack,
|
||||
cancelled: a.cancelled,
|
||||
onTap: onTap,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveTrack = (realTrack?.isNotEmpty ?? false)
|
||||
? realTrack!
|
||||
: (track ?? '');
|
||||
final trackChanged =
|
||||
realTrack != null && track != null && realTrack != track;
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
leading: SizedBox(
|
||||
width: 72,
|
||||
child: ProductChip(product: product, fallbackLabel: name),
|
||||
),
|
||||
title: Text(
|
||||
towards,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
decoration: cancelled ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
subtitle: effectiveTrack.isEmpty
|
||||
? null
|
||||
: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.directions_transit,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Gleis $effectiveTrack',
|
||||
style: TextStyle(
|
||||
color: trackChanged
|
||||
? Colors.red
|
||||
: Theme.of(context).colorScheme.secondary,
|
||||
fontWeight: trackChanged ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: RealtimeTime(
|
||||
scheduled: scheduled,
|
||||
realtime: realtime,
|
||||
delayMinutes: delayMinutes,
|
||||
cancelled: cancelled,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import 'product_chip.dart';
|
||||
import 'realtime_time.dart';
|
||||
|
||||
/// Renders a single [Leg] of a trip with header (line/direction), origin and
|
||||
/// destination times and (optionally) the list of intermediate stops.
|
||||
class LegTile extends StatelessWidget {
|
||||
final Leg leg;
|
||||
final VoidCallback? onShowJourneyDetail;
|
||||
|
||||
const LegTile({super.key, required this.leg, this.onShowJourneyDetail});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isWalk =
|
||||
leg.type == LegType.walk || leg.type == LegType.transfer;
|
||||
final cancelled = leg.cancelled || leg.partCancelled;
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_header(context, isWalk: isWalk, cancelled: cancelled),
|
||||
_endpoint(
|
||||
context,
|
||||
label: leg.origin.name,
|
||||
scheduled: leg.origin.scheduledTime,
|
||||
realtime: leg.origin.realTime,
|
||||
delayMinutes: leg.origin.delayMinutes?.toInt(),
|
||||
track: (leg.origin.realTrack?.isNotEmpty ?? false)
|
||||
? leg.origin.realTrack
|
||||
: leg.origin.track,
|
||||
icon: Icons.trip_origin,
|
||||
cancelled: cancelled,
|
||||
),
|
||||
if (leg.stops.length > 2)
|
||||
_stopsExpander(context, leg.stops),
|
||||
_endpoint(
|
||||
context,
|
||||
label: leg.destination.name,
|
||||
scheduled: leg.destination.scheduledTime,
|
||||
realtime: leg.destination.realTime,
|
||||
delayMinutes: leg.destination.delayMinutes?.toInt(),
|
||||
track: (leg.destination.realTrack?.isNotEmpty ?? false)
|
||||
? leg.destination.realTrack
|
||||
: leg.destination.track,
|
||||
icon: Icons.place,
|
||||
cancelled: cancelled,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(
|
||||
BuildContext context, {
|
||||
required bool isWalk,
|
||||
required bool cancelled,
|
||||
}) {
|
||||
final headlineStyle = Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
decoration: cancelled ? TextDecoration.lineThrough : null,
|
||||
);
|
||||
final title = isWalk
|
||||
? 'Fußweg'
|
||||
: (leg.direction != null
|
||||
? '${leg.name ?? ''} → ${leg.direction}'
|
||||
: (leg.name ?? '—'));
|
||||
final canOpenJourney = leg.journeyRef != null && !isWalk;
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (!isWalk) ProductChip(product: leg.product, fallbackLabel: leg.name),
|
||||
if (!isWalk) const SizedBox(width: 8),
|
||||
if (isWalk)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: Icon(Icons.directions_walk),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: headlineStyle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (leg.duration != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
'${leg.duration!.inMinutes} min',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
if (canOpenJourney)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.list_alt),
|
||||
tooltip: 'Alle Halte',
|
||||
onPressed: onShowJourneyDetail,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _endpoint(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
required DateTime scheduled,
|
||||
DateTime? realtime,
|
||||
int? delayMinutes,
|
||||
String? track,
|
||||
required IconData icon,
|
||||
required bool cancelled,
|
||||
}) => ListTile(
|
||||
leading: Icon(icon, size: 20),
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
decoration: cancelled ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
subtitle: (track != null && track.isNotEmpty)
|
||||
? Text('Gleis $track')
|
||||
: null,
|
||||
trailing: RealtimeTime(
|
||||
scheduled: scheduled,
|
||||
realtime: realtime,
|
||||
delayMinutes: delayMinutes,
|
||||
cancelled: cancelled,
|
||||
),
|
||||
dense: true,
|
||||
);
|
||||
|
||||
Widget _stopsExpander(BuildContext context, List<JourneyStop> stops) {
|
||||
final intermediate = stops.length > 2
|
||||
? stops.sublist(1, stops.length - 1)
|
||||
: const <JourneyStop>[];
|
||||
if (intermediate.isEmpty) return const SizedBox.shrink();
|
||||
return ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
title: Text(
|
||||
'${intermediate.length} Zwischenhalt${intermediate.length > 1 ? 'e' : ''}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
children: intermediate
|
||||
.map(
|
||||
(s) => ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: const Icon(Icons.fiber_manual_record, size: 10),
|
||||
title: Text(s.name),
|
||||
trailing: Text(_stopTime(s)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
String _stopTime(JourneyStop s) {
|
||||
final dep = s.realDeparture ?? s.scheduledDeparture;
|
||||
final arr = s.realArrival ?? s.scheduledArrival;
|
||||
if (arr != null && dep != null && arr != dep) {
|
||||
return '${arr.formatHm()} / ${dep.formatHm()}';
|
||||
}
|
||||
final t = dep ?? arr;
|
||||
return t?.formatHm() ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
|
||||
/// Renders a transit line/product as a compact, colored chip
|
||||
/// (e.g. `U7`, `S3`, `RB51`, `ICE`). Colour is derived from the category code
|
||||
/// so the same line consistently has the same colour.
|
||||
class ProductChip extends StatelessWidget {
|
||||
final Product? product;
|
||||
final String? fallbackLabel;
|
||||
|
||||
const ProductChip({super.key, required this.product, this.fallbackLabel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = _label();
|
||||
if (label == null || label.isEmpty) return const SizedBox.shrink();
|
||||
final color = _colorFor(product?.category, product?.categoryCode);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _label() {
|
||||
final p = product;
|
||||
if (p == null) return fallbackLabel;
|
||||
if (p.line != null && p.line!.isNotEmpty) return p.line;
|
||||
if (p.displayNumber != null && p.displayNumber!.isNotEmpty) {
|
||||
return '${p.category ?? ''}${p.displayNumber}'.trim();
|
||||
}
|
||||
if (p.name != null && p.name!.isNotEmpty) return p.name;
|
||||
return fallbackLabel;
|
||||
}
|
||||
|
||||
Color _colorFor(String? category, String? code) {
|
||||
final key = (category ?? code ?? '').toLowerCase();
|
||||
if (key.startsWith('ice')) return const Color(0xFFD32F2F);
|
||||
if (key.startsWith('ic') || key.startsWith('ec')) {
|
||||
return const Color(0xFFE57373);
|
||||
}
|
||||
if (key.startsWith('s-bahn') || key == 's') return const Color(0xFF2E7D32);
|
||||
if (key.startsWith('u-bahn') || key == 'u') return const Color(0xFF1565C0);
|
||||
if (key.startsWith('tram') || key.startsWith('strab')) {
|
||||
return const Color(0xFFEF6C00);
|
||||
}
|
||||
if (key.startsWith('bus')) return const Color(0xFF6A1B9A);
|
||||
if (key.startsWith('rb') || key.startsWith('re')) {
|
||||
return const Color(0xFF455A64);
|
||||
}
|
||||
return const Color(0xFF37474F);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../extensions/date_time.dart';
|
||||
|
||||
/// Shows a scheduled time with optional realtime delay overlay.
|
||||
///
|
||||
/// Examples:
|
||||
/// - on-time: `14:35`
|
||||
/// - 2 minutes late: `14:35` + green/red `+2'` chip
|
||||
/// - cancelled: scheduled time struck through, red `Ausfall` chip
|
||||
class RealtimeTime extends StatelessWidget {
|
||||
final DateTime scheduled;
|
||||
final DateTime? realtime;
|
||||
final int? delayMinutes;
|
||||
final bool cancelled;
|
||||
final TextStyle? style;
|
||||
|
||||
const RealtimeTime({
|
||||
super.key,
|
||||
required this.scheduled,
|
||||
this.realtime,
|
||||
this.delayMinutes,
|
||||
this.cancelled = false,
|
||||
this.style,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final base = style ?? Theme.of(context).textTheme.bodyMedium ?? const TextStyle();
|
||||
final scheduledText = Text(
|
||||
scheduled.formatHm(),
|
||||
style: base.copyWith(
|
||||
decoration: cancelled ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
);
|
||||
if (cancelled) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
scheduledText,
|
||||
const SizedBox(width: 6),
|
||||
_badge(context, 'Ausfall', Colors.red),
|
||||
],
|
||||
);
|
||||
}
|
||||
final delay = delayMinutes;
|
||||
if (delay != null && delay != 0) {
|
||||
final positive = delay > 0;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
scheduledText,
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${positive ? '+' : ''}$delay\'',
|
||||
style: base.copyWith(
|
||||
color: positive ? Colors.red : Colors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return scheduledText;
|
||||
}
|
||||
|
||||
Widget _badge(BuildContext context, String text, Color color) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import 'product_chip.dart';
|
||||
import 'realtime_time.dart';
|
||||
|
||||
/// Compact summary of a [Trip] used in the trip results list.
|
||||
class TripTile extends StatelessWidget {
|
||||
final Trip trip;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const TripTile({super.key, required this.trip, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final firstLeg = trip.legs.isEmpty ? null : trip.legs.first;
|
||||
final lastLeg = trip.legs.isEmpty ? null : trip.legs.last;
|
||||
if (firstLeg == null || lastLeg == null) {
|
||||
return const ListTile(title: Text('Verbindung ohne Halt'));
|
||||
}
|
||||
final scheduledStart = firstLeg.origin.scheduledTime;
|
||||
final scheduledEnd = lastLeg.destination.scheduledTime;
|
||||
final cancelled =
|
||||
trip.legs.any((l) => l.cancelled || l.partCancelled);
|
||||
final transfers = trip.transferCount ?? _countTransfers(trip);
|
||||
final duration = trip.realDuration ?? trip.duration;
|
||||
final productChips = trip.legs
|
||||
.where((l) => l.type == LegType.journey && l.product != null)
|
||||
.map((l) => Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: ProductChip(product: l.product, fallbackLabel: l.name),
|
||||
))
|
||||
.toList();
|
||||
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
isThreeLine: true,
|
||||
title: Row(
|
||||
children: [
|
||||
RealtimeTime(
|
||||
scheduled: scheduledStart,
|
||||
realtime: firstLeg.origin.realTime,
|
||||
delayMinutes: firstLeg.origin.delayMinutes?.toInt(),
|
||||
cancelled: cancelled,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Text('–'),
|
||||
),
|
||||
RealtimeTime(
|
||||
scheduled: scheduledEnd,
|
||||
realtime: lastLeg.destination.realTime,
|
||||
delayMinutes: lastLeg.destination.delayMinutes?.toInt(),
|
||||
cancelled: cancelled,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Wrap(runSpacing: 4, children: productChips),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
if (duration != null) ...[
|
||||
const Icon(Icons.schedule, size: 14),
|
||||
const SizedBox(width: 2),
|
||||
Text(_formatDuration(duration)),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
const Icon(Icons.swap_horiz, size: 14),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
transfers == 0
|
||||
? 'Direkt'
|
||||
: '$transfers Umstieg${transfers > 1 ? 'e' : ''}',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
);
|
||||
}
|
||||
|
||||
int _countTransfers(Trip trip) {
|
||||
final journeyLegs =
|
||||
trip.legs.where((l) => l.type == LegType.journey).length;
|
||||
return journeyLegs <= 1 ? 0 : journeyLegs - 1;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
final hours = d.inHours;
|
||||
final minutes = d.inMinutes.remainder(60);
|
||||
if (hours == 0) return '$minutes min';
|
||||
return '$hours h ${minutes.toString().padLeft(2, '0')} min';
|
||||
}
|
||||
|
||||
/// Re-export for trip detail screen.
|
||||
String formatTripDuration(Duration d) => _formatDuration(d);
|
||||
|
||||
/// Helper used in date headers on the trip results list.
|
||||
String formatTripDateHeader(DateTime when) => when.formatDateRelativeShort();
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../extensions/date_time.dart';
|
||||
|
||||
/// Returns the "depart at / arrive by" time and the AB/AN-toggle. `null` for
|
||||
/// [value] means "now" — the API treats an empty `when` parameter as the
|
||||
/// current time.
|
||||
class WhenPicker extends StatelessWidget {
|
||||
final DateTime? value;
|
||||
final bool byArrival;
|
||||
final ValueChanged<DateTime?> onValueChanged;
|
||||
final ValueChanged<bool> onByArrivalChanged;
|
||||
|
||||
const WhenPicker({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.byArrival,
|
||||
required this.onValueChanged,
|
||||
required this.onByArrivalChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = value == null
|
||||
? 'Jetzt'
|
||||
: value!.formatDateRelativeShort() == 'Heute'
|
||||
? value!.formatHm()
|
||||
: '${value!.formatDateRelativeShort()} ${value!.formatHm()}';
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.schedule),
|
||||
label: Text(label),
|
||||
onPressed: () => _pick(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment(value: false, label: Text('Ab')),
|
||||
ButtonSegment(value: true, label: Text('An')),
|
||||
],
|
||||
selected: {byArrival},
|
||||
onSelectionChanged: (s) => onByArrivalChanged(s.first),
|
||||
),
|
||||
if (value != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Zurück auf "Jetzt"',
|
||||
onPressed: () => onValueChanged(null),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pick(BuildContext context) async {
|
||||
final now = DateTime.now();
|
||||
final initial = value ?? now;
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initial,
|
||||
firstDate: now.subtract(const Duration(days: 7)),
|
||||
lastDate: now.add(const Duration(days: 365)),
|
||||
);
|
||||
if (date == null) return;
|
||||
if (!context.mounted) return;
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay(hour: initial.hour, minute: initial.minute),
|
||||
);
|
||||
if (time == null) return;
|
||||
onValueChanged(
|
||||
DateTime(date.year, date.month, date.day, time.hour, time.minute),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user