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,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