implemented RMV commute integration in the timetable, added Nominatim geocoding for home station lookup, created CommuteCubit for daily trip management with TTL caching, and introduced specialized timetable tiles, detail sheets, and settings for transit connections and walking buffers

This commit is contained in:
2026-05-20 22:50:57 +02:00
parent 067012cc84
commit 46d6b3410e
20 changed files with 1513 additions and 20 deletions
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import '../../../../../api/connect/rmv/rmv_models.dart';
import '../../../../../extensions/date_time.dart';
import '../../../../../routing/app_routes.dart';
import '../../../../../widget/details_bottom_sheet.dart';
import '../../../rmv/widgets/leg_tile.dart';
import '../../../rmv/widgets/realtime_time.dart';
import '../../data/commute_direction.dart';
/// Reuses the RMV-module LegTile so the in-timetable trip detail looks
/// identical to the regular trip-details view in the RMV module.
void showCommuteDetailsSheet(
BuildContext context, {
required Trip trip,
required CommuteDirection direction,
}) {
final firstLeg = trip.legs.firstOrNull;
final lastLeg = trip.legs.lastOrNull;
showDetailsBottomSheet(
context,
header: Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
direction == CommuteDirection.toSchool
? 'Hinfahrt zur Schule'
: 'Heimfahrt',
style: Theme.of(context).textTheme.titleLarge,
),
if (firstLeg != null && lastLeg != null) ...[
const SizedBox(height: 4),
Text(
'${firstLeg.origin.name}${lastLeg.destination.name}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 4),
Row(
children: [
RealtimeTime(
scheduled: firstLeg.origin.scheduledTime,
realtime: firstLeg.origin.realTime,
delayMinutes: firstLeg.origin.delayMinutes?.toInt(),
style: Theme.of(context).textTheme.titleMedium,
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text(''),
),
RealtimeTime(
scheduled: lastLeg.destination.scheduledTime,
realtime: lastLeg.destination.realTime,
delayMinutes: lastLeg.destination.delayMinutes?.toInt(),
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
if (trip.realDuration != null || trip.duration != null)
Text(
_formatDuration(trip.realDuration ?? trip.duration!),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
],
),
),
children: (sheetCtx) => [
...trip.legs.map(
(l) => LegTile(
leg: l,
onShowJourneyDetail: l.journeyRef == null
? null
: () => AppRoutes.openRmvJourneyDetail(
sheetCtx,
l.journeyRef!,
date: l.origin.scheduledTime,
),
),
),
],
);
}
String _formatDuration(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60);
return h == 0 ? '$m min' : '$h h ${m.toString().padLeft(2, '0')} min';
}
/// Used in headers to label the trip start date relative to today.
String formatCommuteDay(DateTime day) => day.formatDateRelativeShort();
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import '../../../../../api/connect/rmv/rmv_models.dart';
import '../../../../../extensions/date_time.dart';
import '../../data/arbitrary_appointment.dart';
import '../../data/commute_direction.dart';
/// Tile body for [CommuteAppointment]s: bus icon + line label up top, real-time
/// departure with delay marker below. Designed to stay readable in 6080 px
/// tall lanes — collapses gracefully when the lane is shorter.
class CommuteTileContent extends StatelessWidget {
final CommuteAppointment commute;
final bool crossedOut;
const CommuteTileContent({
super.key,
required this.commute,
this.crossedOut = false,
});
@override
Widget build(BuildContext context) {
final trip = commute.trip;
final firstLeg = trip.legs.firstOrNull;
if (firstLeg == null) return const SizedBox.shrink();
final scheduled = firstLeg.origin.scheduledTime;
final realtime = firstLeg.origin.realTime;
final delay = firstLeg.origin.delayMinutes?.toInt();
final lineLabel = _lineLabel(trip);
final track = (firstLeg.origin.realTrack?.isNotEmpty ?? false)
? firstLeg.origin.realTrack
: firstLeg.origin.track;
final cancelled = crossedOut;
final dirIcon = commute.direction == CommuteDirection.toSchool
? Icons.school_outlined
: Icons.home_outlined;
final dirLabel = commute.direction == CommuteDirection.toSchool
? '→ Schule'
: '→ Heimat';
return LayoutBuilder(
builder: (context, constraints) {
final h = constraints.maxHeight;
if (h < 14) return const SizedBox.shrink();
final children = <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(dirIcon, size: 12, color: Colors.white),
const SizedBox(width: 3),
Expanded(
child: Text(
'$lineLabel $dirLabel',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
height: 1.1,
decoration:
cancelled ? TextDecoration.lineThrough : null,
),
),
),
],
),
];
if (h >= 28) {
children.add(const SizedBox(height: 1));
children.add(_timeRow(scheduled, realtime, delay, cancelled));
}
if (h >= 42 && track != null && track.isNotEmpty) {
children.add(
Padding(
padding: const EdgeInsets.only(top: 1),
child: Text(
'Gleis $track',
style: const TextStyle(color: Colors.white70, fontSize: 9, height: 1.1),
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: children,
);
},
);
}
Widget _timeRow(
DateTime scheduled,
DateTime? realtime,
int? delay,
bool cancelled,
) {
final baseStyle = TextStyle(
color: Colors.white,
fontSize: 10,
height: 1.1,
decoration: cancelled ? TextDecoration.lineThrough : null,
);
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(scheduled.formatHm(), style: baseStyle),
if (delay != null && delay != 0) ...[
const SizedBox(width: 3),
Text(
'${delay > 0 ? '+' : ''}$delay\'',
style: baseStyle.copyWith(
color: delay > 0 ? const Color(0xFFFFCDD2) : const Color(0xFFC8E6C9),
fontWeight: FontWeight.w700,
decoration: null,
),
),
],
],
);
}
String _lineLabel(Trip trip) {
final journeys = trip.legs.where((l) => l.type == LegType.journey).toList();
if (journeys.isEmpty) return 'Fußweg';
return journeys.map(_legLabel).join(' ');
}
String _legLabel(Leg leg) {
final p = leg.product;
if (p == null) return leg.name ?? '?';
if (p.line != null && p.line!.isNotEmpty) return p.line!;
if (p.displayNumber != null && p.displayNumber!.isNotEmpty) {
return '${p.category ?? ''}${p.displayNumber}'.trim();
}
return p.name ?? leg.name ?? '?';
}
}