diff --git a/lib/components/pages/loco_timeline.dart b/lib/components/pages/loco_timeline.dart new file mode 100644 index 0000000..13aa0fc --- /dev/null +++ b/lib/components/pages/loco_timeline.dart @@ -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 createState() => _LocoTimelinePageState(); +} + +class _LocoTimelinePageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + Future _load() { + return context.read().fetchLocoTimeline(widget.locoId); + } + + @override + Widget build(BuildContext context) { + final data = context.watch(); + 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 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> attrRows; + final String endLabel; + + _TimelineModel({ + required this.axisSegments, + required this.attrRows, + required this.endLabel, + }); + + factory _TimelineModel.fromEntries(List entries) { + final grouped = >{}; + for (final entry in entries) { + grouped.putIfAbsent(entry.attrCode, () => []).add(entry); + } + final now = DateTime.now(); + DateTime? minStart; + DateTime? maxEnd; + final attrSegments = >{}; + + 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 = {}; + 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 = >{}; + 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(); +} diff --git a/lib/components/pages/traction.dart b/lib/components/pages/traction.dart index 3240407..fdc2b41 100644 --- a/lib/components/pages/traction.dart +++ b/lib/components/pages/traction.dart @@ -574,6 +574,12 @@ class _TractionPageState extends State { icon: const Icon(Icons.info_outline), label: const Text('Details'), ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () => _openTimeline(loco), + icon: const Icon(Icons.timeline), + label: const Text('Timeline'), + ), const Spacer(), if (widget.selectionMode) TextButton.icon( @@ -692,6 +698,14 @@ class _TractionPageState extends State { return (background, foreground); } + void _openTimeline(LocoSummary loco) { + final label = '${loco.locoClass} ${loco.number}'.trim(); + context.push( + '/traction/${loco.id}/timeline', + extra: {'label': label}, + ); + } + Future _showLocoInfo(LocoSummary loco) async { await showModalBottomSheet( context: context, diff --git a/lib/main.dart b/lib/main.dart index c47eea4..dc6c213 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:dynamic_color/dynamic_color.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_traction.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; @@ -95,6 +96,27 @@ class MyApp extends StatelessWidget { ), GoRoute(path: '/legs', builder: (_, __) => LegsPage()), 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( path: '/traction/new', builder: (_, __) => const NewTractionPage(), diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index 721c513..808da0c 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; class 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 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 fromGroupedJson(dynamic json) { + final List items = []; + if (json is Map) { + json.forEach((key, value) { + if (value is List) { + for (final entry in value) { + if (entry is Map) { + final merged = Map.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 { final String userId, username, userFullName; final double mileage; diff --git a/lib/services/dataService.dart b/lib/services/dataService.dart index d84c9cb..6422a76 100644 --- a/lib/services/dataService.dart +++ b/lib/services/dataService.dart @@ -49,6 +49,12 @@ class DataService extends ChangeNotifier { bool get isTractionLoading => _isTractionLoading; bool _tractionHasMore = false; bool get tractionHasMore => _tractionHasMore; + final Map> _locoTimelines = {}; + final Map _isLocoTimelineLoading = {}; + List timelineForLoco(int locoId) => + _locoTimelines[locoId] ?? []; + bool isLocoTimelineLoading(int locoId) => + _isLocoTimelineLoading[locoId] ?? false; // Trips List _trips = []; @@ -235,6 +241,24 @@ class DataService extends ChangeNotifier { } } + Future> 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 createLoco(Map payload) async { try { final response = await api.put('/loco/new', payload); @@ -424,6 +448,8 @@ class DataService extends ChangeNotifier { _trips = []; _tripDetails = []; _eventFields = []; + _locoTimelines.clear(); + _isLocoTimelineLoading.clear(); _notifyAsync(); } diff --git a/pubspec.yaml b/pubspec.yaml index 9a57429..69d2f32 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 0.1.5+1 +version: 0.1.6+1 environment: sdk: ^3.8.1