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
+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();