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:
@@ -3,6 +3,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../data/arbitrary_appointment.dart';
|
||||
import '../data/calendar_layout.dart';
|
||||
import 'commute/commute_tile_content.dart';
|
||||
import 'cross_painter.dart';
|
||||
|
||||
class AppointmentTile extends StatelessWidget {
|
||||
@@ -21,7 +22,9 @@ class AppointmentTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final isPast = appointment.endTime.isBefore(DateTime.now());
|
||||
final color = appointment.color.withAlpha(isPast ? 160 : 255);
|
||||
final isCustom = appointment.id is CustomAppointment;
|
||||
final id = appointment.id;
|
||||
final isCustom = id is CustomAppointment;
|
||||
final isCommute = id is CommuteAppointment;
|
||||
final description = appointment.location ?? '';
|
||||
|
||||
return Padding(
|
||||
@@ -37,11 +40,13 @@ class AppointmentTile extends StatelessWidget {
|
||||
borderRadius: _radius,
|
||||
color: color,
|
||||
),
|
||||
child: _TileContent(
|
||||
title: appointment.subject,
|
||||
description: description,
|
||||
isCustom: isCustom,
|
||||
),
|
||||
child: isCommute
|
||||
? CommuteTileContent(commute: id, crossedOut: crossedOut)
|
||||
: _TileContent(
|
||||
title: appointment.subject,
|
||||
description: description,
|
||||
isCustom: isCustom,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (crossedOut)
|
||||
|
||||
@@ -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 60–80 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 ?? '?';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user