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:
2026-05-20 19:08:05 +02:00
parent f185b3273a
commit 067012cc84
61 changed files with 7885 additions and 1 deletions
@@ -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,
),
);
}
}
+175
View File
@@ -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);
},
);
}
+108
View File
@@ -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),
);
}
}