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:
@@ -1,5 +1,7 @@
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||
import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart';
|
||||
import 'commute_direction.dart';
|
||||
|
||||
sealed class ArbitraryAppointment {
|
||||
const ArbitraryAppointment();
|
||||
@@ -7,9 +9,12 @@ sealed class ArbitraryAppointment {
|
||||
T when<T>({
|
||||
required T Function(GetTimetableResponseObject lesson) webuntis,
|
||||
required T Function(CustomTimetableEvent event) custom,
|
||||
required T Function(Trip trip, CommuteDirection direction) commute,
|
||||
}) => switch (this) {
|
||||
WebuntisAppointment(:final lesson) => webuntis(lesson),
|
||||
CustomAppointment(:final event) => custom(event),
|
||||
CommuteAppointment(:final trip, :final direction) =>
|
||||
commute(trip, direction),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,3 +27,9 @@ class CustomAppointment extends ArbitraryAppointment {
|
||||
final CustomTimetableEvent event;
|
||||
const CustomAppointment(this.event);
|
||||
}
|
||||
|
||||
class CommuteAppointment extends ArbitraryAppointment {
|
||||
final Trip trip;
|
||||
final CommuteDirection direction;
|
||||
const CommuteAppointment(this.trip, this.direction);
|
||||
}
|
||||
|
||||
@@ -282,8 +282,9 @@ class LaidOutOverflow extends LaidOutCell {
|
||||
int _appointmentPriority(Appointment a) {
|
||||
final id = a.id;
|
||||
if (id is CustomAppointment) return 0;
|
||||
if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1;
|
||||
return 2;
|
||||
if (id is CommuteAppointment) return 1;
|
||||
if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
/// Assigns each appointment a lane index using a greedy sweep, then collapses
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../api/connect/rmv/rmv_models.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import 'arbitrary_appointment.dart';
|
||||
import 'commute_direction.dart';
|
||||
|
||||
/// Builds [Appointment] objects from RMV [Trip]s so the existing timetable
|
||||
/// lane layout can render them next to school lessons. Per-trip cancellation
|
||||
/// flips the color to red; the [appointment.id] always wraps the original
|
||||
/// [Trip] so the tap handler can surface its details.
|
||||
class CommuteAppointmentFactory {
|
||||
static const Color colorMorning = Color(0xFFFB8C00); // amber 600
|
||||
static const Color colorEvening = Color(0xFF8E24AA); // purple 600
|
||||
static const Color colorCancelled = Color(0xFFE53935); // red 600
|
||||
|
||||
/// Converts every entry in [morning]/[evening] into an Appointment.
|
||||
static List<Appointment> build({
|
||||
required List<Trip> morning,
|
||||
required List<Trip> evening,
|
||||
}) => [
|
||||
for (final trip in morning) ?_tripToAppointment(trip, CommuteDirection.toSchool),
|
||||
for (final trip in evening) ?_tripToAppointment(trip, CommuteDirection.fromSchool),
|
||||
];
|
||||
|
||||
static Appointment? _tripToAppointment(Trip trip, CommuteDirection direction) {
|
||||
final firstLeg = trip.legs.firstOrNull;
|
||||
final lastLeg = trip.legs.lastOrNull;
|
||||
if (firstLeg == null || lastLeg == null) return null;
|
||||
final start = firstLeg.origin.scheduledTime;
|
||||
final end = lastLeg.destination.scheduledTime;
|
||||
if (!end.isAfter(start)) return null;
|
||||
|
||||
final cancelled =
|
||||
trip.legs.every((l) => l.cancelled || l.partCancelled) &&
|
||||
trip.legs.isNotEmpty;
|
||||
final color = cancelled
|
||||
? colorCancelled
|
||||
: (direction == CommuteDirection.toSchool
|
||||
? colorMorning
|
||||
: colorEvening);
|
||||
|
||||
return Appointment(
|
||||
id: CommuteAppointment(trip, direction),
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
subject: _subject(trip),
|
||||
location: _location(direction, start, end),
|
||||
color: color,
|
||||
startTimeZone: '',
|
||||
endTimeZone: '',
|
||||
);
|
||||
}
|
||||
|
||||
static String _subject(Trip trip) {
|
||||
final lines = trip.legs
|
||||
.where((l) => l.type == LegType.journey)
|
||||
.map(_legLabel)
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
if (lines.isEmpty) return 'Fußweg';
|
||||
return lines.join(' › ');
|
||||
}
|
||||
|
||||
static 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 ?? '?';
|
||||
}
|
||||
|
||||
static String _location(
|
||||
CommuteDirection direction,
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
) {
|
||||
final label = direction == CommuteDirection.toSchool ? 'Hinfahrt' : 'Heimfahrt';
|
||||
return '$label\n${start.formatHm()}–${end.formatHm()}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/// Direction of a commute trip relative to the school day.
|
||||
enum CommuteDirection { toSchool, fromSchool }
|
||||
@@ -3,6 +3,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../data/arbitrary_appointment.dart';
|
||||
import '../widgets/commute/commute_details_sheet.dart';
|
||||
import 'custom_event_sheet.dart';
|
||||
import 'webuntis_lesson_sheet.dart';
|
||||
|
||||
@@ -19,6 +20,8 @@ class AppointmentDetailsDispatcher {
|
||||
webuntis: (lesson) =>
|
||||
WebuntisLessonSheet.show(context, bloc, appointment, lesson),
|
||||
custom: (event) => CustomEventSheet.show(context, event),
|
||||
commute: (trip, direction) =>
|
||||
showCommuteDetailsSheet(context, trip: trip, direction: direction),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||
import '../../../state/app/modules/commute/bloc/commute_cubit.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../storage/timetable_settings.dart';
|
||||
import 'custom_events/custom_event_edit_dialog.dart';
|
||||
import 'data/arbitrary_appointment.dart';
|
||||
import 'data/commute_appointment_factory.dart';
|
||||
import 'data/lesson_period_schedule.dart';
|
||||
import 'data/timetable_appointment_factory.dart';
|
||||
import 'data/webuntis_time.dart';
|
||||
@@ -33,6 +35,7 @@ class _TimetableState extends State<Timetable> {
|
||||
List<Appointment>? _cachedAppointments;
|
||||
int? _lastDataVersion;
|
||||
TimetableSettings? _lastTimetableSettings;
|
||||
Map<String, CommuteDayEntry>? _lastCommuteState;
|
||||
|
||||
DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2));
|
||||
|
||||
@@ -58,15 +61,23 @@ class _TimetableState extends State<Timetable> {
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.timetableSettings;
|
||||
final commuteState = context.watch<CommuteCubit>().state;
|
||||
|
||||
// Kick off any missing commute fetches for the currently visible weeks.
|
||||
// The cubit's ttl/inflight guards make this safe to call on every build.
|
||||
_maybeRequestCommute(state, timetableSettings);
|
||||
|
||||
if (_cachedAppointments != null &&
|
||||
_lastDataVersion == state.dataVersion &&
|
||||
identical(_lastTimetableSettings, timetableSettings)) {
|
||||
identical(_lastTimetableSettings, timetableSettings) &&
|
||||
identical(_lastCommuteState, commuteState)) {
|
||||
return _cachedAppointments!;
|
||||
}
|
||||
_lastDataVersion = state.dataVersion;
|
||||
_lastTimetableSettings = timetableSettings;
|
||||
_lastCommuteState = commuteState;
|
||||
|
||||
return _cachedAppointments = TimetableAppointmentFactory(
|
||||
final base = TimetableAppointmentFactory(
|
||||
lessons: state.getAllKnownLessons().toList(),
|
||||
customEvents: state.customEvents?.events ?? const [],
|
||||
rooms: state.rooms!,
|
||||
@@ -74,11 +85,74 @@ class _TimetableState extends State<Timetable> {
|
||||
settings: timetableSettings,
|
||||
now: DateTime.now(),
|
||||
).build();
|
||||
|
||||
if (!timetableSettings.showCommuteInTimetable || commuteState.isEmpty) {
|
||||
return _cachedAppointments = base;
|
||||
}
|
||||
|
||||
final commute = <Appointment>[];
|
||||
for (final entry in commuteState.values) {
|
||||
commute.addAll(
|
||||
CommuteAppointmentFactory.build(
|
||||
morning: entry.morning,
|
||||
evening: entry.evening,
|
||||
),
|
||||
);
|
||||
}
|
||||
return _cachedAppointments = [...base, ...commute];
|
||||
}
|
||||
|
||||
void _maybeRequestCommute(
|
||||
TimetableState state,
|
||||
TimetableSettings timetableSettings,
|
||||
) {
|
||||
if (!timetableSettings.showCommuteInTimetable) return;
|
||||
if (timetableSettings.homeStation == null) return;
|
||||
if (timetableSettings.schoolStation == null) return;
|
||||
|
||||
final spans = _lessonSpansByDay(state);
|
||||
if (spans.isEmpty) return;
|
||||
|
||||
context.read<CommuteCubit>().ensureLoaded(
|
||||
lessonsByDay: spans,
|
||||
settings: timetableSettings,
|
||||
);
|
||||
}
|
||||
|
||||
Map<DateTime, LessonSpan> _lessonSpansByDay(TimetableState state) {
|
||||
final byDay = <DateTime, _MinMax>{};
|
||||
for (final lesson in state.getAllKnownLessons()) {
|
||||
try {
|
||||
final start = WebuntisTime.parse(lesson.date, lesson.startTime);
|
||||
final end = WebuntisTime.parse(lesson.date, lesson.endTime);
|
||||
final day = DateTime(start.year, start.month, start.day);
|
||||
final existing = byDay[day];
|
||||
if (existing == null) {
|
||||
byDay[day] = _MinMax(start, end);
|
||||
} else {
|
||||
if (start.isBefore(existing.min)) existing.min = start;
|
||||
if (end.isAfter(existing.max)) existing.max = end;
|
||||
}
|
||||
} catch (_) {
|
||||
// Skip lessons we can't parse — same fallback as elsewhere.
|
||||
}
|
||||
}
|
||||
return {
|
||||
for (final entry in byDay.entries)
|
||||
entry.key: LessonSpan(entry.value.min, entry.value.max),
|
||||
};
|
||||
}
|
||||
|
||||
bool _isCrossedOut(Appointment appointment) {
|
||||
final id = appointment.id;
|
||||
if (id is WebuntisAppointment) return id.lesson.code == 'cancelled';
|
||||
if (id is CommuteAppointment) {
|
||||
// Strike the tile only if literally every leg is cancelled — partially
|
||||
// cancelled trips still get the user somewhere and should stay legible.
|
||||
final legs = id.trip.legs;
|
||||
return legs.isNotEmpty &&
|
||||
legs.every((l) => l.cancelled || l.partCancelled);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -217,3 +291,9 @@ class _TimetableState extends State<Timetable> {
|
||||
return (mondayMin, effectiveMax);
|
||||
}
|
||||
}
|
||||
|
||||
class _MinMax {
|
||||
DateTime min;
|
||||
DateTime max;
|
||||
_MinMax(this.min, this.max);
|
||||
}
|
||||
|
||||
@@ -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