3 Commits

Author SHA1 Message Date
80c315866f add timeline
Some checks failed
Release / meta (push) Successful in 9s
Release / linux-build (push) Failing after 6m22s
Release / android-build (push) Failing after 14m39s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2025-12-15 16:02:21 +00:00
da70dce369 drafts minor changes, edit minor changes
All checks were successful
Release / meta (push) Successful in 15s
Release / linux-build (push) Successful in 9m20s
Release / android-build (push) Successful in 25m33s
Release / release-master (push) Successful in 43s
Release / release-dev (push) Successful in 45s
2025-12-15 00:33:18 +00:00
603e117af8 add draft changes
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m18s
Release / android-build (push) Successful in 16m30s
Release / release-master (push) Successful in 24s
Release / release-dev (push) Successful in 27s
2025-12-14 23:30:45 +00:00
11 changed files with 1660 additions and 130 deletions

View File

@@ -97,10 +97,16 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
} }
class RouteCalculator extends StatefulWidget { class RouteCalculator extends StatefulWidget {
const RouteCalculator({super.key, this.onDistanceComputed, this.onApplyRoute}); const RouteCalculator({
super.key,
this.onDistanceComputed,
this.onApplyRoute,
this.initialStations,
});
final ValueChanged<double>? onDistanceComputed; final ValueChanged<double>? onDistanceComputed;
final ValueChanged<RouteResult>? onApplyRoute; final ValueChanged<RouteResult>? onApplyRoute;
final List<String>? initialStations;
@override @override
State<RouteCalculator> createState() => _RouteCalculatorState(); State<RouteCalculator> createState() => _RouteCalculatorState();
@@ -122,6 +128,9 @@ class _RouteCalculatorState extends State<RouteCalculator> {
super.didChangeDependencies(); super.didChangeDependencies();
if (!_fetched) { if (!_fetched) {
_fetched = true; _fetched = true;
if (widget.initialStations != null && widget.initialStations!.isNotEmpty) {
context.read<DataService>().stations = List.from(widget.initialStations!);
}
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final result = await data.fetchStations(); final result = await data.fetchStations();

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -279,6 +280,11 @@ class _LegsPageState extends State<LegsPage> {
trailing: Column( trailing: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
IconButton(
tooltip: 'Edit entry',
icon: const Icon(Icons.edit),
onPressed: () => context.push('/legs/edit/${leg.id}'),
),
Text( Text(
'${leg.mileage.toStringAsFixed(1)} mi', '${leg.mileage.toStringAsFixed(1)} mi',
style: style:

View File

@@ -0,0 +1,544 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/dataService.dart';
import 'package:provider/provider.dart';
class LocoTimelinePage extends StatefulWidget {
const LocoTimelinePage({
super.key,
required this.locoId,
required this.locoLabel,
});
final int locoId;
final String locoLabel;
@override
State<LocoTimelinePage> createState() => _LocoTimelinePageState();
}
class _LocoTimelinePageState extends State<LocoTimelinePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() {
return context.read<DataService>().fetchLocoTimeline(widget.locoId);
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final timeline = data.timelineForLoco(widget.locoId);
final isLoading = data.isLocoTimelineLoading(widget.locoId);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: Text('Timeline · ${widget.locoLabel}'),
),
body: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.all(16),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Text(
'Attribute timeline',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'Each row is an attribute. Blocks stretch between change dates; scroll sideways for history.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
if (isLoading && timeline.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Center(child: CircularProgressIndicator()),
)
else if (timeline.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No timeline data yet',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text(
'This locomotive does not have any attribute history to show right now.',
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Try again'),
),
],
),
),
)
else
_TimelineGrid(entries: timeline),
],
),
),
);
}
}
class _TimelineGrid extends StatelessWidget {
const _TimelineGrid({required this.entries});
final List<LocoAttrVersion> entries;
@override
Widget build(BuildContext context) {
final model = _TimelineModel.fromEntries(entries);
final axisSegments = model.axisSegments;
const labelWidth = 150.0;
const rowHeight = 52.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Scrollbar(
thumbVisibility: true,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_AxisRow(
labelWidth: labelWidth,
segments: axisSegments,
endLabel: model.endLabel,
),
const SizedBox(height: 8),
...model.attrRows.entries.map(
(entry) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: _AttrRow(
labelWidth: labelWidth,
rowHeight: rowHeight,
attrLabel: _formatAttrLabel(entry.key),
cells: entry.value,
segments: axisSegments,
),
),
),
],
),
),
),
),
const SizedBox(height: 14),
Text(
'Dates mark when a value changed. The right edge of the last block is “Now” if no end date is present.',
style: Theme.of(context).textTheme.labelMedium,
),
],
);
}
}
class _AxisRow extends StatelessWidget {
const _AxisRow({
required this.labelWidth,
required this.segments,
required this.endLabel,
});
final double labelWidth;
final List<_AxisSegment> segments;
final String endLabel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: labelWidth,
height: 36,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Date',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
...segments.asMap().entries.map(
(entry) {
final i = entry.key;
final seg = entry.value;
final isLast = i == segments.length - 1;
return SizedBox(
width: seg.width,
height: 36,
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
seg.label,
style: theme.textTheme.labelSmall,
),
),
if (isLast)
Align(
alignment: Alignment.centerRight,
child: Text(
endLabel,
style: theme.textTheme.labelSmall,
),
),
],
),
);
},
),
],
);
}
}
class _AttrRow extends StatelessWidget {
const _AttrRow({
required this.labelWidth,
required this.rowHeight,
required this.attrLabel,
required this.cells,
required this.segments,
});
final double labelWidth;
final double rowHeight;
final String attrLabel;
final List<_RowCell> cells;
final List<_AxisSegment> segments;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: labelWidth,
height: rowHeight,
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Text(
attrLabel,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
...segments.asMap().entries.map((entry) {
final idx = entry.key;
final seg = entry.value;
final cell = cells[idx];
final hasValue = cell.value.isNotEmpty;
final textColor = ThemeData.estimateBrightnessForColor(
cell.color.withOpacity(0.9),
) ==
Brightness.dark
? Colors.white
: Colors.black87;
return Container(
width: seg.width,
height: rowHeight,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: hasValue
? cell.color.withOpacity(0.9)
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
),
child: hasValue
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
cell.value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
color: textColor,
) ??
TextStyle(
fontWeight: FontWeight.w700,
color: textColor,
),
),
const SizedBox(height: 4),
Text(
cell.rangeLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: textColor.withOpacity(0.9),
) ??
TextStyle(color: textColor.withOpacity(0.9)),
),
],
)
: const SizedBox.shrink(),
);
}),
],
);
}
}
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
String? _formatDate(DateTime? date) {
if (date == null) return null;
return _dateFormat.format(date);
}
String _formatAttrLabel(String code) {
if (code.isEmpty) return 'Attribute';
final parts = code.split('_').where((p) => p.isNotEmpty).toList();
if (parts.isEmpty) return code;
return parts
.map((part) => part.length == 1
? part.toUpperCase()
: part[0].toUpperCase() + part.substring(1))
.join(' ');
}
DateTime? _parseDateString(String? value) {
if (value == null || value.isEmpty) return null;
return DateTime.tryParse(value);
}
DateTime? _effectiveStart(LocoAttrVersion entry) {
return entry.validFrom ??
_parseDateString(entry.maskedValidFrom) ??
entry.txnFrom;
}
DateTime _safeEnd(DateTime start, DateTime? end) {
if (end == null || !end.isAfter(start)) {
return start.add(const Duration(days: 1));
}
return end;
}
class _TimelineModel {
final List<_AxisSegment> axisSegments;
final Map<String, List<_RowCell>> attrRows;
final String endLabel;
_TimelineModel({
required this.axisSegments,
required this.attrRows,
required this.endLabel,
});
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) {
final grouped = <String, List<LocoAttrVersion>>{};
for (final entry in entries) {
grouped.putIfAbsent(entry.attrCode, () => []).add(entry);
}
final now = DateTime.now();
DateTime? minStart;
DateTime? maxEnd;
final attrSegments = <String, List<_ValueSegment>>{};
grouped.forEach((attr, items) {
items.sort(
(a, b) => (_effectiveStart(a) ?? now)
.compareTo(_effectiveStart(b) ?? now),
);
final segments = <_ValueSegment>[];
for (int i = 0; i < items.length; i++) {
final entry = items[i];
final start = _effectiveStart(entry) ?? now;
final nextStart = i < items.length - 1
? _effectiveStart(items[i + 1])
: null;
final rawEnd = entry.validTo ?? nextStart ?? now;
final end = _safeEnd(start, rawEnd);
segments.add(
_ValueSegment(
start: start,
end: end,
value: entry.valueLabel,
entry: entry,
),
);
minStart = minStart == null || start.isBefore(minStart!)
? start
: minStart;
maxEnd = maxEnd == null || end.isAfter(maxEnd!) ? end : maxEnd;
}
attrSegments[attr] = segments;
});
minStart ??= now.subtract(const Duration(days: 1));
maxEnd ??= now;
final boundaryDates = <DateTime>{};
for (final segments in attrSegments.values) {
for (final seg in segments) {
boundaryDates.add(seg.start);
}
}
boundaryDates.add(maxEnd);
var boundaries = boundaryDates.toList()..sort();
if (boundaries.length < 2) {
boundaries = [minStart!, maxEnd];
}
final axisSegments = <_AxisSegment>[];
const pxPerDay = 4.0;
for (int i = 0; i < boundaries.length - 1; i++) {
final start = boundaries[i];
final end = boundaries[i + 1];
final days = (end.difference(start).inDays).clamp(1, 365);
final width = (days * pxPerDay) + 30;
axisSegments.add(
_AxisSegment(
start: start,
end: end,
width: width,
label: _formatDate(start) ?? '',
),
);
}
final attrRows = <String, List<_RowCell>>{};
for (final entry in attrSegments.entries) {
final cells = <_RowCell>[];
for (final axis in axisSegments) {
final seg = entry.value
.firstWhere(
(s) => s.overlaps(axis.start, axis.end),
orElse: () => _ValueSegment.empty(axis.start, axis.end),
);
cells.add(_RowCell.fromSegment(seg));
}
attrRows[entry.key] = cells;
}
final endLabel = _formatDate(maxEnd) ?? 'Now';
return _TimelineModel(
axisSegments: axisSegments,
attrRows: attrRows,
endLabel: endLabel,
);
}
}
class _AxisSegment {
final DateTime start;
final DateTime end;
final double width;
final String label;
_AxisSegment({
required this.start,
required this.end,
required this.width,
required this.label,
});
}
class _ValueSegment {
final DateTime start;
final DateTime end;
final String value;
final LocoAttrVersion? entry;
_ValueSegment({
required this.start,
required this.end,
required this.value,
this.entry,
});
factory _ValueSegment.empty(DateTime start, DateTime end) => _ValueSegment(
start: start,
end: end,
value: '',
entry: null,
);
bool overlaps(DateTime s, DateTime e) {
return start.isBefore(e) && end.isAfter(s);
}
}
class _RowCell {
final String value;
final String rangeLabel;
final Color color;
const _RowCell({
required this.value,
required this.rangeLabel,
required this.color,
});
factory _RowCell.fromSegment(_ValueSegment seg) {
if (seg.value.isEmpty) {
return const _RowCell(
value: '',
rangeLabel: '',
color: Colors.transparent,
);
}
final displayStart = _formatDate(seg.start) ?? '';
final displayEnd = _formatDate(seg.entry?.validTo ?? seg.end) ?? 'Now';
final range = '$displayStart$displayEnd';
return _RowCell(
value: seg.value,
rangeLabel: range,
color: _colorForValue(seg.value),
);
}
}
Color _colorForValue(String value) {
final hue = (value.hashCode % 360).toDouble();
final hsl = HSLColor.fromAHSL(1, hue, 0.55, 0.55);
return hsl.toColor();
}

File diff suppressed because it is too large Load Diff

View File

@@ -574,6 +574,12 @@ class _TractionPageState extends State<TractionPage> {
icon: const Icon(Icons.info_outline), icon: const Icon(Icons.info_outline),
label: const Text('Details'), label: const Text('Details'),
), ),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () => _openTimeline(loco),
icon: const Icon(Icons.timeline),
label: const Text('Timeline'),
),
const Spacer(), const Spacer(),
if (widget.selectionMode) if (widget.selectionMode)
TextButton.icon( TextButton.icon(
@@ -692,6 +698,14 @@ class _TractionPageState extends State<TractionPage> {
return (background, foreground); return (background, foreground);
} }
void _openTimeline(LocoSummary loco) {
final label = '${loco.locoClass} ${loco.number}'.trim();
context.push(
'/traction/${loco.id}/timeline',
extra: {'label': label},
);
}
Future<void> _showLocoInfo(LocoSummary loco) async { Future<void> _showLocoInfo(LocoSummary loco) async {
await showModalBottomSheet( await showModalBottomSheet(
context: context, context: context,

View File

@@ -301,6 +301,25 @@ class _TripsPageState extends State<TripsPage> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (!loading && items.isNotEmpty) ...[
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(
avatar: const Icon(Icons.train, size: 16),
label: Text('Total had: ${items.length}'),
),
Chip(
avatar: const Icon(Icons.star, size: 16),
label: Text(
'Winners: ${items.where((e) => e.won == true).length}',
),
),
],
),
const SizedBox(height: 8),
],
if (loading) if (loading)
const Center( const Center(
child: Padding( child: Padding(

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:mileograph_flutter/components/pages/calculator.dart'; import 'package:mileograph_flutter/components/pages/calculator.dart';
import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
import 'package:mileograph_flutter/components/pages/new_entry.dart'; import 'package:mileograph_flutter/components/pages/new_entry.dart';
import 'package:mileograph_flutter/components/pages/new_traction.dart'; import 'package:mileograph_flutter/components/pages/new_traction.dart';
import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/pages/traction.dart';
@@ -11,6 +12,7 @@ import 'package:mileograph_flutter/components/pages/legs.dart';
import 'package:mileograph_flutter/services/apiService.dart'; import 'package:mileograph_flutter/services/apiService.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/dataService.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'components/login/login.dart'; import 'components/login/login.dart';
import 'components/pages/dashboard.dart'; import 'components/pages/dashboard.dart';
@@ -94,12 +96,41 @@ class MyApp extends StatelessWidget {
), ),
GoRoute(path: '/legs', builder: (_, __) => LegsPage()), GoRoute(path: '/legs', builder: (_, __) => LegsPage()),
GoRoute(path: '/traction', builder: (_, __) => TractionPage()), GoRoute(path: '/traction', builder: (_, __) => TractionPage()),
GoRoute(
path: '/traction/:id/timeline',
builder: (_, state) {
final idParam = state.pathParameters['id'];
final locoId = int.tryParse(idParam ?? '') ?? 0;
final extra = state.extra;
String label = state.uri.queryParameters['label'] ?? '';
if (extra is Map && extra['label'] is String) {
label = extra['label'] as String;
} else if (extra is String && extra.isNotEmpty) {
label = extra;
}
if (label.trim().isEmpty) {
label = 'Loco $locoId';
}
return LocoTimelinePage(
locoId: locoId,
locoLabel: label,
);
},
),
GoRoute( GoRoute(
path: '/traction/new', path: '/traction/new',
builder: (_, __) => const NewTractionPage(), builder: (_, __) => const NewTractionPage(),
), ),
GoRoute(path: '/trips', builder: (_, __) => TripsPage()), GoRoute(path: '/trips', builder: (_, __) => TripsPage()),
GoRoute(path: '/add', builder: (_, __) => NewEntryPage()), GoRoute(path: '/add', builder: (_, __) => NewEntryPage()),
GoRoute(
path: '/legs/edit/:id',
builder: (_, state) {
final idParam = state.pathParameters['id'];
final legId = idParam == null ? null : int.tryParse(idParam);
return NewEntryPage(editLegId: legId);
},
),
], ],
), ),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
@@ -180,12 +211,14 @@ class _MyHomePageState extends State<MyHomePage> {
return newIndex; return newIndex;
} }
void _onItemTapped(int index, int currentIndex) { Future<void> _onItemTapped(int index, int currentIndex) async {
if (index < 0 || index >= contentPages.length || index == currentIndex) { if (index < 0 || index >= contentPages.length || index == currentIndex) {
return; return;
} }
context.push(contentPages[index]); await NavigationGuard.attemptNavigation(() async {
_getIndexFromLocation(contentPages[index]); if (!mounted) return;
context.go(contentPages[index]);
});
} }
bool loggedIn = false; bool loggedIn = false;

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class DestinationObject { class DestinationObject {
const DestinationObject( const DestinationObject(
@@ -190,6 +191,143 @@ class LocoSummary extends Loco {
); );
} }
class LocoAttrVersion {
final String attrCode;
final int? versionId;
final int locoId;
final int? attrTypeId;
final String? valueStr;
final int? valueInt;
final DateTime? valueDate;
final bool? valueBool;
final String? valueEnum;
final DateTime? validFrom;
final DateTime? validTo;
final DateTime? txnFrom;
final DateTime? txnTo;
final String? suggestedBy;
final String? approvedBy;
final DateTime? approvedAt;
final int? sourceEventId;
final String? precisionLevel;
final String? maskedValidFrom;
final dynamic valueNorm;
const LocoAttrVersion({
required this.attrCode,
required this.locoId,
this.versionId,
this.attrTypeId,
this.valueStr,
this.valueInt,
this.valueDate,
this.valueBool,
this.valueEnum,
this.validFrom,
this.validTo,
this.txnFrom,
this.txnTo,
this.suggestedBy,
this.approvedBy,
this.approvedAt,
this.sourceEventId,
this.precisionLevel,
this.maskedValidFrom,
this.valueNorm,
});
factory LocoAttrVersion.fromJson(Map<String, dynamic> json) {
return LocoAttrVersion(
attrCode: json['attr_code']?.toString() ?? '',
locoId: (json['loco_id'] as num?)?.toInt() ?? 0,
versionId: (json['loco_attr_v_id'] as num?)?.toInt(),
attrTypeId: (json['attr_type_id'] as num?)?.toInt(),
valueStr: json['value_str']?.toString(),
valueInt: (json['value_int'] as num?)?.toInt(),
valueDate: _parseDate(json['value_date']),
valueBool: _parseBool(json['value_bool']),
valueEnum: json['value_enum']?.toString(),
validFrom: _parseDate(json['valid_from']),
validTo: _parseDate(json['valid_to']),
txnFrom: _parseDate(json['txn_from']),
txnTo: _parseDate(json['txn_to']),
suggestedBy: json['suggested_by']?.toString(),
approvedBy: json['approved_by']?.toString(),
approvedAt: _parseDate(json['approved_at']),
sourceEventId: (json['source_event_id'] as num?)?.toInt(),
precisionLevel: json['precision_level']?.toString(),
maskedValidFrom: json['masked_valid_from']?.toString(),
valueNorm: json['value_norm'],
);
}
static DateTime? _parseDate(dynamic value) {
if (value == null) return null;
if (value is DateTime) return value;
return DateTime.tryParse(value.toString());
}
static bool? _parseBool(dynamic value) {
if (value == null) return null;
if (value is bool) return value;
if (value is num) return value != 0;
final str = value.toString().toLowerCase();
if (['true', '1', 'yes'].contains(str)) return true;
if (['false', '0', 'no'].contains(str)) return false;
return null;
}
static List<LocoAttrVersion> fromGroupedJson(dynamic json) {
final List<LocoAttrVersion> items = [];
if (json is Map) {
json.forEach((key, value) {
if (value is List) {
for (final entry in value) {
if (entry is Map<String, dynamic>) {
final merged = Map<String, dynamic>.from(entry);
merged.putIfAbsent('attr_code', () => key);
items.add(LocoAttrVersion.fromJson(merged));
}
}
}
});
}
items.sort(
(a, b) {
final aDate = a.validFrom ?? a.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = b.validFrom ?? b.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0);
final dateCompare = aDate.compareTo(bDate);
if (dateCompare != 0) return dateCompare;
return a.attrCode.compareTo(b.attrCode);
},
);
return items;
}
String get valueLabel {
if (valueStr != null && valueStr!.isNotEmpty) return valueStr!;
if (valueEnum != null && valueEnum!.isNotEmpty) return valueEnum!;
if (valueInt != null) return valueInt!.toString();
if (valueBool != null) return valueBool! ? 'Yes' : 'No';
if (valueDate != null) return DateFormat('yyyy-MM-dd').format(valueDate!);
if (valueNorm != null && valueNorm.toString().isNotEmpty) {
return valueNorm.toString();
}
return '';
}
String get validityRange {
final start = maskedValidFrom ?? _formatDate(validFrom) ?? 'Unknown';
final end = _formatDate(validTo, fallback: 'Present') ?? 'Present';
return '$start$end';
}
String? _formatDate(DateTime? value, {String? fallback}) {
if (value == null) return fallback;
return DateFormat('yyyy-MM-dd').format(value);
}
}
class LeaderboardEntry { class LeaderboardEntry {
final String userId, username, userFullName; final String userId, username, userFullName;
final double mileage; final double mileage;

View File

@@ -49,6 +49,12 @@ class DataService extends ChangeNotifier {
bool get isTractionLoading => _isTractionLoading; bool get isTractionLoading => _isTractionLoading;
bool _tractionHasMore = false; bool _tractionHasMore = false;
bool get tractionHasMore => _tractionHasMore; bool get tractionHasMore => _tractionHasMore;
final Map<int, List<LocoAttrVersion>> _locoTimelines = {};
final Map<int, bool> _isLocoTimelineLoading = {};
List<LocoAttrVersion> timelineForLoco(int locoId) =>
_locoTimelines[locoId] ?? [];
bool isLocoTimelineLoading(int locoId) =>
_isLocoTimelineLoading[locoId] ?? false;
// Trips // Trips
List<TripSummary> _trips = []; List<TripSummary> _trips = [];
@@ -235,6 +241,24 @@ class DataService extends ChangeNotifier {
} }
} }
Future<List<LocoAttrVersion>> fetchLocoTimeline(int locoId) async {
_isLocoTimelineLoading[locoId] = true;
_notifyAsync();
try {
final json = await api.get('/loco/get-timeline/$locoId');
final timeline = LocoAttrVersion.fromGroupedJson(json);
_locoTimelines[locoId] = timeline;
return timeline;
} catch (e) {
debugPrint('Failed to fetch loco timeline for $locoId: $e');
_locoTimelines[locoId] = [];
return [];
} finally {
_isLocoTimelineLoading[locoId] = false;
_notifyAsync();
}
}
Future<dynamic> createLoco(Map<String, dynamic> payload) async { Future<dynamic> createLoco(Map<String, dynamic> payload) async {
try { try {
final response = await api.put('/loco/new', payload); final response = await api.put('/loco/new', payload);
@@ -424,6 +448,8 @@ class DataService extends ChangeNotifier {
_trips = []; _trips = [];
_tripDetails = []; _tripDetails = [];
_eventFields = []; _eventFields = [];
_locoTimelines.clear();
_isLocoTimelineLoading.clear();
_notifyAsync(); _notifyAsync();
} }

View File

@@ -0,0 +1,40 @@
typedef NavigationGuardCallback = Future<bool> Function();
class NavigationGuard {
static NavigationGuardCallback? _callback;
static void register(NavigationGuardCallback callback) {
_callback = callback;
}
static void unregister(NavigationGuardCallback callback) {
if (_callback == callback) {
_callback = null;
}
}
static Future<void> attemptNavigation(
Future<void> Function() performNavigation,
) async {
if (_promptActive) return;
final cb = _callback;
if (cb == null) {
await performNavigation();
return;
}
_promptActive = true;
bool allow = false;
try {
allow = await cb();
} catch (_) {
allow = false;
} finally {
_promptActive = false;
}
if (allow) {
await performNavigation();
}
}
static bool _promptActive = false;
}

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.1.3+1 version: 0.1.6+1
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1