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,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() ?? '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user