From 80be7973221857cb70c7eb6d330356b94597b513 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Tue, 16 Dec 2025 16:49:39 +0000 Subject: [PATCH] major refactor --- .gitea/workflows/release.yml | 4 +- lib/components/calculator/calculator.dart | 6 +- ...yWidget.dart => route_summary_widget.dart} | 0 ...boardPanel.dart => leaderboard_panel.dart} | 2 +- ...tionPanel.dart => top_traction_panel.dart} | 2 +- lib/components/pages/dashboard.dart | 12 +- lib/components/pages/legs.dart | 2 +- lib/components/pages/loco_timeline.dart | 1060 +---------------- .../pages/loco_timeline/event_editor.dart | 331 +++++ .../pages/loco_timeline/timeline_grid.dart | 700 +++++++++++ lib/components/pages/new_entry.dart | 59 +- lib/components/pages/new_traction.dart | 2 +- lib/components/pages/traction.dart | 346 +----- lib/components/pages/trips.dart | 2 +- lib/components/traction/traction_card.dart | 336 ++++++ lib/main.dart | 28 +- lib/objects/objects.dart | 16 +- .../{apiService.dart => api_service.dart} | 0 lib/services/authservice.dart | 16 +- .../{dataService.dart => data_service.dart} | 2 +- ...ervice.dart => token_storage_service.dart} | 0 pubspec.lock | 2 +- pubspec.yaml | 3 +- 23 files changed, 1517 insertions(+), 1414 deletions(-) rename lib/components/calculator/{routeSummaryWidget.dart => route_summary_widget.dart} (100%) rename lib/components/dashboard/{leaderboardPanel.dart => leaderboard_panel.dart} (97%) rename lib/components/dashboard/{topTractionPanel.dart => top_traction_panel.dart} (98%) create mode 100644 lib/components/pages/loco_timeline/event_editor.dart create mode 100644 lib/components/pages/loco_timeline/timeline_grid.dart create mode 100644 lib/components/traction/traction_card.dart rename lib/services/{apiService.dart => api_service.dart} (100%) rename lib/services/{dataService.dart => data_service.dart} (99%) rename lib/services/{tokenStorageService.dart => token_storage_service.dart} (100%) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 537a452..db65d30 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -235,7 +235,7 @@ jobs: id: bundle run: | BASE="${{ needs.meta.outputs.base_version }}" - TAG="v${BASE}-dev.${{ github.run_number }}" + TAG="v${BASE}-dev" mv "artifacts/mileograph-${BASE}.apk" "artifacts/mileograph-${BASE}-dev.apk" @@ -247,7 +247,7 @@ jobs: uses: ncipollo/release-action@v1 with: tag: ${{ steps.bundle.outputs.tag }} - name: v${{ needs.meta.outputs.base_version }}-dev build ${{ github.run_number }} + name: ${{ steps.bundle.outputs.tag }} prerelease: true commit: ${{ github.sha }} token: ${{ secrets.GITEA_TOKEN }} diff --git a/lib/components/calculator/calculator.dart b/lib/components/calculator/calculator.dart index fda03e0..ff3a153 100644 --- a/lib/components/calculator/calculator.dart +++ b/lib/components/calculator/calculator.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:mileograph_flutter/objects/objects.dart'; -import 'package:mileograph_flutter/services/apiService.dart'; -import 'package:mileograph_flutter/services/dataService.dart'; -import './routeSummaryWidget.dart'; +import 'package:mileograph_flutter/services/api_service.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; +import './route_summary_widget.dart'; class StationAutocomplete extends StatefulWidget { const StationAutocomplete({ diff --git a/lib/components/calculator/routeSummaryWidget.dart b/lib/components/calculator/route_summary_widget.dart similarity index 100% rename from lib/components/calculator/routeSummaryWidget.dart rename to lib/components/calculator/route_summary_widget.dart diff --git a/lib/components/dashboard/leaderboardPanel.dart b/lib/components/dashboard/leaderboard_panel.dart similarity index 97% rename from lib/components/dashboard/leaderboardPanel.dart rename to lib/components/dashboard/leaderboard_panel.dart index e5b7935..88b559e 100644 --- a/lib/components/dashboard/leaderboardPanel.dart +++ b/lib/components/dashboard/leaderboard_panel.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; diff --git a/lib/components/dashboard/topTractionPanel.dart b/lib/components/dashboard/top_traction_panel.dart similarity index 98% rename from lib/components/dashboard/topTractionPanel.dart rename to lib/components/dashboard/top_traction_panel.dart index 1782cce..4ad6b8d 100644 --- a/lib/components/dashboard/topTractionPanel.dart +++ b/lib/components/dashboard/top_traction_panel.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; diff --git a/lib/components/pages/dashboard.dart b/lib/components/pages/dashboard.dart index c946a52..9171806 100644 --- a/lib/components/pages/dashboard.dart +++ b/lib/components/pages/dashboard.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:mileograph_flutter/components/dashboard/leaderboardPanel.dart'; -import 'package:mileograph_flutter/components/dashboard/topTractionPanel.dart'; +import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart'; +import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/authservice.dart'; -import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; class Dashboard extends StatefulWidget { @@ -78,7 +78,7 @@ class _DashboardState extends State { child: Container( color: Theme.of( context, - ).colorScheme.surface.withOpacity(0.7), + ).colorScheme.surface.withValues(alpha: 0.7), child: const Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -105,7 +105,7 @@ class _DashboardState extends State { bool loading, ) { final greetingName = - stats?.user?.full_name ?? auth.fullName ?? auth.username ?? 'there'; + stats?.user?.fullName ?? auth.fullName ?? auth.username ?? 'there'; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, @@ -154,7 +154,7 @@ class _DashboardState extends State { label.toUpperCase(), style: textTheme.labelSmall?.copyWith( letterSpacing: 0.7, - color: textTheme.bodySmall?.color?.withOpacity(0.7), + color: textTheme.bodySmall?.color?.withValues(alpha: 0.7), ), ), const SizedBox(height: 4), diff --git a/lib/components/pages/legs.dart b/lib/components/pages/legs.dart index dc38af9..e61c3a5 100644 --- a/lib/components/pages/legs.dart +++ b/lib/components/pages/legs.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/objects/objects.dart'; -import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; class LegsPage extends StatefulWidget { diff --git a/lib/components/pages/loco_timeline.dart b/lib/components/pages/loco_timeline.dart index 8131145..0346a92 100644 --- a/lib/components/pages/loco_timeline.dart +++ b/lib/components/pages/loco_timeline.dart @@ -3,9 +3,12 @@ import 'dart:math' as math; 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:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; +part 'loco_timeline/timeline_grid.dart'; +part 'loco_timeline/event_editor.dart'; + class LocoTimelinePage extends StatefulWidget { const LocoTimelinePage({ super.key, @@ -30,6 +33,12 @@ class _LocoTimelinePageState extends State { WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } + @override + void dispose() { + _disposeDrafts(_draftEvents); + super.dispose(); + } + Future _load() { final data = context.read(); data.fetchEventFields(); @@ -42,6 +51,24 @@ class _LocoTimelinePageState extends State { }); } + void _removeDraftAt(int index) { + if (index < 0 || index >= _draftEvents.length) return; + final draft = _draftEvents.removeAt(index); + _disposeDraft(draft); + setState(() {}); + } + + void _disposeDraft(_EventDraft draft) { + draft.dateController.dispose(); + draft.detailsController.dispose(); + } + + void _disposeDrafts(List<_EventDraft> drafts) { + for (final draft in drafts) { + _disposeDraft(draft); + } + } + Future _saveEvents() async { if (_isSaving) return; if (!_canSaveDrafts()) { @@ -96,6 +123,7 @@ class _LocoTimelinePageState extends State { } return; } + _disposeDrafts(_draftEvents); _draftEvents.clear(); await _load(); if (mounted) { @@ -221,6 +249,7 @@ class _LocoTimelinePageState extends State { onAddEvent: _addDraftEvent, onChange: () => setState(() {}), onSave: _saveEvents, + onRemoveDraft: _removeDraftAt, isSaving: _isSaving, canSave: _canSaveDrafts(), ), @@ -232,1032 +261,3 @@ class _LocoTimelinePageState extends State { ); } } - -class _TimelineGrid extends StatefulWidget { - const _TimelineGrid({ - required this.entries, - }); - - final List entries; - - @override - State<_TimelineGrid> createState() => _TimelineGridState(); -} - -class _TimelineGridState extends State<_TimelineGrid> { - final ScrollController _horizontalController = ScrollController(); - final ScrollController _rightVerticalController = ScrollController(); - final ScrollController _leftVerticalController = ScrollController(); - bool _isSyncingScroll = false; - double _scrollOffset = 0; - - @override - void initState() { - super.initState(); - _rightVerticalController.addListener(_syncVerticalScroll); - _horizontalController.addListener(_onHorizontalScroll); - } - - @override - void dispose() { - _rightVerticalController.removeListener(_syncVerticalScroll); - _horizontalController.removeListener(_onHorizontalScroll); - _horizontalController.dispose(); - _rightVerticalController.dispose(); - _leftVerticalController.dispose(); - super.dispose(); - } - - void _syncVerticalScroll() { - if (_isSyncingScroll) return; - if (!_leftVerticalController.hasClients || - !_rightVerticalController.hasClients) { - return; - } - _isSyncingScroll = true; - _leftVerticalController.jumpTo( - _rightVerticalController.offset.clamp( - 0.0, - _leftVerticalController.position.maxScrollExtent, - ), - ); - _isSyncingScroll = false; - } - - void _onHorizontalScroll() { - if (!mounted) return; - setState(() { - _scrollOffset = _horizontalController.offset; - }); - } - - @override - Widget build(BuildContext context) { - final filteredEntries = widget.entries.where((e) { - final code = e.attrCode.toLowerCase(); - return !{ - 'operational', - 'gettable', - 'build_prec', - 'build_year', - 'build_month', - 'build_day', - }.contains(code); - }).toList(); - final model = _TimelineModel.fromEntries(filteredEntries); - final axisSegments = model.axisSegments; - const labelWidth = 110.0; - const rowHeight = 52.0; - const double axisHeight = 48; - final rows = model.attrRows.entries.toList(); - final totalRowsHeight = rows.length * rowHeight; - final axisWidth = math.max(model.axisTotalWidth, 120.0); - final double viewHeight = totalRowsHeight + axisHeight + 8; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: viewHeight, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: labelWidth, - child: Column( - children: [ - SizedBox( - height: axisHeight, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'Attribute', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - ), - Expanded( - child: Scrollbar( - controller: _leftVerticalController, - thumbVisibility: true, - child: ListView.builder( - controller: _leftVerticalController, - physics: const NeverScrollableScrollPhysics(), - itemExtent: rowHeight, - itemCount: rows.length, - itemBuilder: (_, index) { - final label = _formatAttrLabel(rows[index].key); - return Container( - alignment: Alignment.centerLeft, - padding: const EdgeInsets.symmetric( - horizontal: 10, - ), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - border: Border( - bottom: BorderSide( - color: - Theme.of(context).colorScheme.outlineVariant, - ), - ), - ), - child: Text( - label, - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith(fontWeight: FontWeight.w700), - ), - ); - }, - ), - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Expanded( - child: Scrollbar( - controller: _horizontalController, - thumbVisibility: true, - child: SingleChildScrollView( - controller: _horizontalController, - scrollDirection: Axis.horizontal, - child: SizedBox( - width: axisWidth, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _AxisRow( - segments: axisSegments, - totalWidth: axisWidth, - endLabel: model.endLabel, - ), - const SizedBox(height: 8), - Expanded( - child: ListView.builder( - controller: _rightVerticalController, - itemExtent: rowHeight, - itemCount: rows.length, - itemBuilder: (_, index) { - final blocks = rows[index].value; - return Padding( - padding: - const EdgeInsets.symmetric(vertical: 2.0), - child: _AttrRow( - rowHeight: rowHeight, - blocks: blocks, - model: model, - scrollOffset: _scrollOffset, - viewportWidth: axisWidth, - ), - ); - }, - ), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ), - ], - ); - } -} - -class _AxisRow extends StatelessWidget { - const _AxisRow({ - required this.segments, - required this.endLabel, - required this.totalWidth, - }); - - final List<_AxisSegment> segments; - final String endLabel; - final double totalWidth; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - const double axisHeight = 48; - return SizedBox( - width: totalWidth, - height: axisHeight, - child: Stack( - children: [ - for (int i = 0; i < segments.length; i++) ...[ - Positioned( - left: segments[i].offset, - width: segments[i].width, - top: 0, - bottom: 0, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - segments[i].label, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: theme.textTheme.labelSmall, - ), - ), - ), - ], - Positioned( - right: 0, - top: 0, - bottom: 0, - child: Align( - alignment: Alignment.centerRight, - child: Text( - endLabel, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: theme.textTheme.labelSmall, - ), - ), - ), - ], - ), - ); - } -} - -class _AttrRow extends StatelessWidget { - const _AttrRow({ - required this.rowHeight, - required this.blocks, - required this.model, - required this.scrollOffset, - required this.viewportWidth, - }); - - final double rowHeight; - final List<_ValueBlock> blocks; - final _TimelineModel model; - final double scrollOffset; - final double viewportWidth; - - @override - Widget build(BuildContext context) { - final width = math.max(model.axisTotalWidth, 120.0); - final activeBlock = _activeBlock(blocks, scrollOffset); - final double stickyWidth = activeBlock == null - ? 0 - : (activeBlock.right - scrollOffset).clamp(20.0, viewportWidth); - - return SizedBox( - width: width, - height: rowHeight, - child: Stack( - clipBehavior: Clip.hardEdge, - children: [ - for (final block in blocks) - Positioned( - left: block.left, - width: block.width, - top: 0, - bottom: 0, - child: _ValueBlockView(block: block), - ), - if (activeBlock != null) - Positioned( - left: scrollOffset, - width: stickyWidth, - top: 0, - bottom: 0, - child: IgnorePointer( - child: ClipRect( - child: _ValueBlockView( - block: activeBlock.copyWith( - left: scrollOffset, - width: stickyWidth, - ), - clipLeftEdge: scrollOffset > activeBlock.left + 0.1, - ), - ), - ), - ), - ], - ), - ); - } - - _ValueBlock? _activeBlock(List<_ValueBlock> blocks, double offset) { - for (final block in blocks) { - if (offset >= block.left && offset < block.right) return block; - } - return null; - } -} - -class _ValueBlockView extends StatelessWidget { - const _ValueBlockView({ - required this.block, - this.clipLeftEdge = false, - }); - - final _ValueBlock block; - final bool clipLeftEdge; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final color = block.cell.color.withOpacity(0.9); - final textColor = ThemeData.estimateBrightnessForColor(color) == - Brightness.dark - ? Colors.white - : Colors.black87; - - final radius = BorderRadius.only( - topLeft: Radius.circular(clipLeftEdge ? 0 : 12), - bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12), - topRight: const Radius.circular(12), - bottomRight: const Radius.circular(12), - ); - - return ClipRRect( - borderRadius: radius, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: block.cell.value.isEmpty - ? theme.colorScheme.surfaceContainerHighest - : color, - borderRadius: BorderRadius.zero, - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: block.cell.value.isEmpty - ? const SizedBox.shrink() - : FittedBox( - alignment: Alignment.topLeft, - fit: BoxFit.scaleDown, - child: ConstrainedBox( - constraints: const BoxConstraints(minWidth: 1, minHeight: 1), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - block.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( - block.cell.rangeLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith( - color: textColor.withOpacity(0.9), - ) ?? - TextStyle(color: textColor.withOpacity(0.9)), - ), - ], - ), - ), - ), - ), - ); - } -} -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; - final List boundaries; - final double axisTotalWidth; - - _TimelineModel({ - required this.axisSegments, - required this.attrRows, - required this.endLabel, - required this.boundaries, - required this.axisTotalWidth, - }); - - 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); - if (segments.isNotEmpty && segments.last.value == entry.valueLabel) { - final last = segments.removeLast(); - segments.add( - last.copyWith( - end: end.isAfter(last.end) ? end : last.end, - ), - ); - } else { - 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)); - final effectiveMaxEnd = maxEnd ?? now; - - final boundaryDates = {}; - for (final segments in attrSegments.values) { - for (final seg in segments) { - boundaryDates.add(seg.start); - boundaryDates.add(seg.end); - } - } - boundaryDates.add(effectiveMaxEnd); - var boundaries = boundaryDates.toList()..sort(); - if (boundaries.length < 2) { - boundaries = [minStart!, effectiveMaxEnd]; - } - - final axisSegments = <_AxisSegment>[]; - const double yearWidth = 240.0; - for (int i = 0; i < boundaries.length - 1; i++) { - final start = boundaries[i]; - final end = boundaries[i + 1]; - final width = yearWidth; - final double offset = axisSegments.isEmpty - ? 0.0 - : axisSegments.last.offset + axisSegments.last.width; - axisSegments.add( - _AxisSegment( - start: start, - end: end, - width: width, - offset: offset, - label: _formatDate(start) ?? '', - ), - ); - } - final axisTotalWidth = - axisSegments.fold(0, (sum, seg) => sum + seg.width); - - final attrRows = >{}; - for (final entry in attrSegments.entries) { - final blocks = <_ValueBlock>[]; - for (final seg in entry.value) { - final left = _positionForDate(seg.start, boundaries, axisSegments); - final right = _positionForDate(seg.end, boundaries, axisSegments); - final span = right - left; - final width = span < 2.0 ? 2.0 : span; - blocks.add( - _ValueBlock( - left: left, - width: width, - cell: _RowCell.fromSegment(seg), - ), - ); - } - attrRows[entry.key] = blocks; - } - - final endLabel = _formatDate(effectiveMaxEnd) ?? 'Now'; - return _TimelineModel( - axisSegments: axisSegments, - attrRows: attrRows, - endLabel: endLabel, - boundaries: boundaries, - axisTotalWidth: axisTotalWidth, - ); - } - - static double _positionForDate( - DateTime date, - List boundaries, - List<_AxisSegment> segments, - ) { - for (int i = 0; i < boundaries.length - 1; i++) { - final start = boundaries[i]; - final end = boundaries[i + 1]; - if (!date.isAfter(end)) { - final seg = segments[i]; - final span = end.difference(start).inMilliseconds; - final elapsed = date.difference(start).inMilliseconds.clamp(0, span); - if (span <= 0) return seg.offset; - final fraction = elapsed / span; - return seg.offset + (seg.width * fraction); - } - } - return segments.isNotEmpty - ? segments.last.offset + segments.last.width - : 0.0; - } -} - -class _AxisSegment { - final DateTime start; - final DateTime end; - final double width; - final double offset; - final String label; - - _AxisSegment({ - required this.start, - required this.end, - required this.width, - required this.offset, - 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, - }); - - bool overlaps(DateTime s, DateTime e) { - return start.isBefore(e) && end.isAfter(s); - } - - _ValueSegment copyWith({DateTime? start, DateTime? end, String? value}) { - return _ValueSegment( - start: start ?? this.start, - end: end ?? this.end, - value: value ?? this.value, - entry: entry, - ); - } -} - -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) ?? ''; - return _RowCell( - value: seg.value, - rangeLabel: displayStart, - color: _colorForValue(seg.value), - ); - } -} - -class _ValueBlock { - final double left; - final double width; - final _RowCell cell; - - const _ValueBlock({ - required this.left, - required this.width, - required this.cell, - }); - - double get right => left + width; - - _ValueBlock copyWith({ - double? left, - double? width, - _RowCell? cell, - }) { - return _ValueBlock( - left: left ?? this.left, - width: width ?? this.width, - cell: cell ?? this.cell, - ); - } -} - -class _EventEditor extends StatelessWidget { - const _EventEditor({ - required this.eventFields, - required this.drafts, - required this.onAddEvent, - required this.onChange, - required this.onSave, - required this.isSaving, - required this.canSave, - }); - - final List eventFields; - final List<_EventDraft> drafts; - final VoidCallback onAddEvent; - final VoidCallback onChange; - final Future Function() onSave; - final bool isSaving; - final bool canSave; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Add events', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - Row( - children: [ - OutlinedButton.icon( - onPressed: onAddEvent, - icon: const Icon(Icons.add), - label: const Text('New event'), - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: (!canSave || isSaving) ? null : onSave, - icon: isSaving - ? const SizedBox( - height: 14, - width: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save), - label: Text(isSaving ? 'Saving...' : 'Save all'), - ), - ], - ), - ], - ), - const SizedBox(height: 12), - if (drafts.isEmpty) - const Text('No events yet. Add one to propose new values.') - else - ...drafts.asMap().entries.map( - (entry) { - final idx = entry.key; - final draft = entry.value; - return Padding( - padding: const EdgeInsets.only(bottom: 12.0), - child: Card( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Event ${idx + 1}', - style: Theme.of(context).textTheme.titleMedium, - ), - const Spacer(), - IconButton( - tooltip: 'Remove', - onPressed: () { - drafts.removeAt(idx); - onChange(); - }, - icon: const Icon(Icons.delete_outline), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField( - controller: draft.dateController, - onChanged: (_) => onChange(), - decoration: const InputDecoration( - labelText: 'Date (YYYY-MM-DD, MM/DD can be XX)', - border: OutlineInputBorder(), - ), - ), - ), - IconButton( - tooltip: 'Pick date', - onPressed: () async { - final now = DateTime.now(); - final picked = await showDatePicker( - context: context, - initialDate: draft.date ?? now, - firstDate: DateTime(1900), - lastDate: DateTime(now.year + 10), - ); - if (picked != null) { - draft.date = picked; - draft.dateController.text = - DateFormat('yyyy-MM-dd').format(picked); - onChange(); - } - }, - icon: const Icon(Icons.calendar_month), - ), - ], - ), - const SizedBox(height: 8), - _FieldList( - draft: draft, - eventFields: eventFields, - onChange: onChange, - ), - const SizedBox(height: 12), - TextField( - controller: draft.detailsController, - onChanged: (val) { - draft.details = val; - onChange(); - }, - decoration: const InputDecoration( - labelText: 'Commit message / details', - border: OutlineInputBorder(), - ), - ), - ], - ), - ), - ), - ); - }, - ), - ], - ); - } -} - -class _FieldList extends StatelessWidget { - const _FieldList({ - required this.draft, - required this.eventFields, - required this.onChange, - }); - - final _EventDraft draft; - final List eventFields; - final VoidCallback onChange; - - @override - Widget build(BuildContext context) { - final usedNames = draft.fields.map((f) => f.field.name).toSet(); - final availableFields = - eventFields.where((f) => !usedNames.contains(f.name)).toList(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Fields', - style: Theme.of(context).textTheme.titleSmall, - ), - const Spacer(), - DropdownButton( - hint: const Text('Add field'), - value: null, - onChanged: (field) { - if (field == null) return; - draft.fields.add(_FieldEntry(field: field)); - onChange(); - }, - items: availableFields - .map( - (f) => DropdownMenuItem( - value: f, - child: Text(f.display), - ), - ) - .toList(), - ), - ], - ), - const SizedBox(height: 8), - if (draft.fields.isEmpty) - const Text('No fields added yet.') - else - ...draft.fields.asMap().entries.map( - (entry) { - final idx = entry.key; - final field = entry.value; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - field.field.display, - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: 4), - _FieldInput( - field: field.field, - value: field.value, - onChanged: (val) { - field.value = val; - onChange(); - }, - ), - ], - ), - ), - IconButton( - onPressed: () { - draft.fields.removeAt(idx); - onChange(); - }, - icon: const Icon(Icons.close), - ), - ], - ), - ); - }, - ), - ], - ); - } -} - -class _FieldInput extends StatelessWidget { - const _FieldInput({ - required this.field, - required this.value, - required this.onChanged, - }); - - final EventField field; - final dynamic value; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - if (field.enumValues != null && field.enumValues!.isNotEmpty) { - final options = field.enumValues!; - return DropdownButtonFormField( - value: value is String && options.contains(value) ? value : null, - decoration: const InputDecoration(border: OutlineInputBorder()), - items: options - .map((v) => DropdownMenuItem(value: v, child: Text(v))) - .toList(), - onChanged: (val) => onChanged(val), - hint: const Text('Select value'), - ); - } - - final type = field.type?.toLowerCase(); - if (type == 'bool' || type == 'boolean') { - final bool? current = - value is bool ? value : (value is String ? value == 'true' : null); - return DropdownButtonFormField( - value: current, - decoration: const InputDecoration(border: OutlineInputBorder()), - items: const [ - DropdownMenuItem(value: true, child: Text('Yes')), - DropdownMenuItem(value: false, child: Text('No')), - ], - onChanged: (val) => onChanged(val), - hint: const Text('Select'), - ); - } - - final isNumber = type == 'int' || type == 'integer'; - return TextFormField( - initialValue: value?.toString(), - onChanged: (val) { - if (isNumber) { - final parsed = int.tryParse(val); - onChanged(parsed); - } else { - onChanged(val); - } - }, - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: 'Enter value', - ), - keyboardType: isNumber ? TextInputType.number : TextInputType.text, - ); - } -} - -class _EventDraft { - DateTime? date; - String details = ''; - final TextEditingController detailsController = TextEditingController(); - final TextEditingController dateController = TextEditingController(); - final List<_FieldEntry> fields = []; - - _EventDraft(); -} - -class _FieldEntry { - final EventField field; - dynamic value; - - _FieldEntry({required this.field, this.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/loco_timeline/event_editor.dart b/lib/components/pages/loco_timeline/event_editor.dart new file mode 100644 index 0000000..01ccc45 --- /dev/null +++ b/lib/components/pages/loco_timeline/event_editor.dart @@ -0,0 +1,331 @@ +part of 'package:mileograph_flutter/components/pages/loco_timeline.dart'; + +class _EventEditor extends StatelessWidget { + const _EventEditor({ + required this.eventFields, + required this.drafts, + required this.onAddEvent, + required this.onChange, + required this.onSave, + required this.onRemoveDraft, + required this.isSaving, + required this.canSave, + }); + + final List eventFields; + final List<_EventDraft> drafts; + final VoidCallback onAddEvent; + final VoidCallback onChange; + final Future Function() onSave; + final void Function(int index) onRemoveDraft; + final bool isSaving; + final bool canSave; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Add events', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Row( + children: [ + OutlinedButton.icon( + onPressed: onAddEvent, + icon: const Icon(Icons.add), + label: const Text('New event'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: (!canSave || isSaving) ? null : onSave, + icon: isSaving + ? const SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: Text(isSaving ? 'Saving...' : 'Save all'), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + if (drafts.isEmpty) + const Text('No events yet. Add one to propose new values.') + else + ...drafts.asMap().entries.map( + (entry) { + final idx = entry.key; + final draft = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Event ${idx + 1}', + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + IconButton( + tooltip: 'Remove', + onPressed: () => onRemoveDraft(idx), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: draft.dateController, + onChanged: (_) => onChange(), + decoration: const InputDecoration( + labelText: 'Date (YYYY-MM-DD, MM/DD can be XX)', + border: OutlineInputBorder(), + ), + ), + ), + IconButton( + tooltip: 'Pick date', + onPressed: () async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: draft.date ?? now, + firstDate: DateTime(1900), + lastDate: DateTime(now.year + 10), + ); + if (picked != null) { + draft.date = picked; + draft.dateController.text = + DateFormat('yyyy-MM-dd').format(picked); + onChange(); + } + }, + icon: const Icon(Icons.calendar_month), + ), + ], + ), + const SizedBox(height: 8), + _FieldList( + draft: draft, + eventFields: eventFields, + onChange: onChange, + ), + const SizedBox(height: 12), + TextField( + controller: draft.detailsController, + onChanged: (val) { + draft.details = val; + onChange(); + }, + decoration: const InputDecoration( + labelText: 'Commit message / details', + border: OutlineInputBorder(), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ], + ); + } +} + +class _FieldList extends StatelessWidget { + const _FieldList({ + required this.draft, + required this.eventFields, + required this.onChange, + }); + + final _EventDraft draft; + final List eventFields; + final VoidCallback onChange; + + @override + Widget build(BuildContext context) { + final usedNames = draft.fields.map((f) => f.field.name).toSet(); + final availableFields = + eventFields.where((f) => !usedNames.contains(f.name)).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Fields', + style: Theme.of(context).textTheme.titleSmall, + ), + const Spacer(), + DropdownButton( + hint: const Text('Add field'), + value: null, + onChanged: (field) { + if (field == null) return; + draft.fields.add(_FieldEntry(field: field)); + onChange(); + }, + items: availableFields + .map( + (f) => DropdownMenuItem( + value: f, + child: Text(f.display), + ), + ) + .toList(), + ), + ], + ), + const SizedBox(height: 8), + if (draft.fields.isEmpty) + const Text('No fields added yet.') + else + ...draft.fields.asMap().entries.map( + (entry) { + final idx = entry.key; + final field = entry.value; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + field.field.display, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + _FieldInput( + field: field.field, + value: field.value, + onChanged: (val) { + field.value = val; + onChange(); + }, + ), + ], + ), + ), + IconButton( + onPressed: () { + draft.fields.removeAt(idx); + onChange(); + }, + icon: const Icon(Icons.close), + ), + ], + ), + ); + }, + ), + ], + ); + } +} + +class _FieldInput extends StatelessWidget { + const _FieldInput({ + required this.field, + required this.value, + required this.onChanged, + }); + + final EventField field; + final dynamic value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (field.enumValues != null && field.enumValues!.isNotEmpty) { + final options = field.enumValues!; + return DropdownButtonFormField( + value: value is String && options.contains(value) ? value : null, + decoration: const InputDecoration(border: OutlineInputBorder()), + items: options + .map((v) => DropdownMenuItem(value: v, child: Text(v))) + .toList(), + onChanged: (val) => onChanged(val), + hint: const Text('Select value'), + ); + } + + final type = field.type?.toLowerCase(); + if (type == 'bool' || type == 'boolean') { + final bool? current = + value is bool ? value : (value is String ? value == 'true' : null); + return DropdownButtonFormField( + value: current, + decoration: const InputDecoration(border: OutlineInputBorder()), + items: const [ + DropdownMenuItem(value: true, child: Text('Yes')), + DropdownMenuItem(value: false, child: Text('No')), + ], + onChanged: (val) => onChanged(val), + hint: const Text('Select'), + ); + } + + final isNumber = type == 'int' || type == 'integer'; + return TextFormField( + initialValue: value?.toString(), + onChanged: (val) { + if (isNumber) { + final parsed = int.tryParse(val); + onChanged(parsed); + } else { + onChanged(val); + } + }, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Enter value', + ), + keyboardType: isNumber ? TextInputType.number : TextInputType.text, + ); + } +} + +class _EventDraft { + DateTime? date; + String details = ''; + final TextEditingController detailsController = TextEditingController(); + final TextEditingController dateController = TextEditingController(); + final List<_FieldEntry> fields = []; + + _EventDraft(); +} + +class _FieldEntry { + final EventField field; + dynamic value; + + _FieldEntry({required this.field}); +} diff --git a/lib/components/pages/loco_timeline/timeline_grid.dart b/lib/components/pages/loco_timeline/timeline_grid.dart new file mode 100644 index 0000000..0acb94c --- /dev/null +++ b/lib/components/pages/loco_timeline/timeline_grid.dart @@ -0,0 +1,700 @@ +part of 'package:mileograph_flutter/components/pages/loco_timeline.dart'; + +final DateFormat _dateFormat = DateFormat('yyyy-MM-dd'); + +class _TimelineGrid extends StatefulWidget { + const _TimelineGrid({ + required this.entries, + }); + + final List entries; + + @override + State<_TimelineGrid> createState() => _TimelineGridState(); +} + +class _TimelineGridState extends State<_TimelineGrid> { + final ScrollController _horizontalController = ScrollController(); + final ScrollController _rightVerticalController = ScrollController(); + final ScrollController _leftVerticalController = ScrollController(); + bool _isSyncingScroll = false; + double _scrollOffset = 0; + + @override + void initState() { + super.initState(); + _rightVerticalController.addListener(_syncVerticalScroll); + _horizontalController.addListener(_onHorizontalScroll); + } + + @override + void dispose() { + _rightVerticalController.removeListener(_syncVerticalScroll); + _horizontalController.removeListener(_onHorizontalScroll); + _horizontalController.dispose(); + _rightVerticalController.dispose(); + _leftVerticalController.dispose(); + super.dispose(); + } + + void _syncVerticalScroll() { + if (_isSyncingScroll) return; + if (!_leftVerticalController.hasClients || + !_rightVerticalController.hasClients) { + return; + } + _isSyncingScroll = true; + _leftVerticalController.jumpTo( + _rightVerticalController.offset.clamp( + 0.0, + _leftVerticalController.position.maxScrollExtent, + ), + ); + _isSyncingScroll = false; + } + + void _onHorizontalScroll() { + if (!mounted) return; + setState(() { + _scrollOffset = _horizontalController.offset; + }); + } + + @override + Widget build(BuildContext context) { + final filteredEntries = widget.entries.where((e) { + final code = e.attrCode.toLowerCase(); + return !{ + 'operational', + 'gettable', + 'build_prec', + 'build_year', + 'build_month', + 'build_day', + }.contains(code); + }).toList(); + final model = _TimelineModel.fromEntries(filteredEntries); + final axisSegments = model.axisSegments; + const labelWidth = 110.0; + const rowHeight = 52.0; + const double axisHeight = 48; + final rows = model.attrRows.entries.toList(); + final totalRowsHeight = rows.length * rowHeight; + final axisWidth = math.max(model.axisTotalWidth, 120.0); + final double viewHeight = totalRowsHeight + axisHeight + 8; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: viewHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: labelWidth, + child: Column( + children: [ + SizedBox( + height: axisHeight, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Attribute', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ), + Expanded( + child: Scrollbar( + controller: _leftVerticalController, + thumbVisibility: true, + child: ListView.builder( + controller: _leftVerticalController, + physics: const NeverScrollableScrollPhysics(), + itemExtent: rowHeight, + itemCount: rows.length, + itemBuilder: (_, index) { + final label = _formatAttrLabel(rows[index].key); + return Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric( + horizontal: 10, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + border: Border( + bottom: BorderSide( + color: + Theme.of(context).colorScheme.outlineVariant, + ), + ), + ), + child: Text( + label, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + ); + }, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Expanded( + child: Scrollbar( + controller: _horizontalController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _horizontalController, + scrollDirection: Axis.horizontal, + child: SizedBox( + width: axisWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AxisRow( + segments: axisSegments, + totalWidth: axisWidth, + endLabel: model.endLabel, + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + controller: _rightVerticalController, + itemExtent: rowHeight, + itemCount: rows.length, + itemBuilder: (_, index) { + final blocks = rows[index].value; + return Padding( + padding: + const EdgeInsets.symmetric(vertical: 2.0), + child: _AttrRow( + rowHeight: rowHeight, + blocks: blocks, + model: model, + scrollOffset: _scrollOffset, + viewportWidth: axisWidth, + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _AxisRow extends StatelessWidget { + const _AxisRow({ + required this.segments, + required this.endLabel, + required this.totalWidth, + }); + + final List<_AxisSegment> segments; + final String endLabel; + final double totalWidth; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const double axisHeight = 48; + return SizedBox( + width: totalWidth, + height: axisHeight, + child: Stack( + children: [ + for (int i = 0; i < segments.length; i++) ...[ + Positioned( + left: segments[i].offset, + width: segments[i].width, + top: 0, + bottom: 0, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + segments[i].label, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: theme.textTheme.labelSmall, + ), + ), + ), + ], + Positioned( + right: 0, + top: 0, + bottom: 0, + child: Align( + alignment: Alignment.centerRight, + child: Text( + endLabel, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: theme.textTheme.labelSmall, + ), + ), + ), + ], + ), + ); + } +} + +class _AttrRow extends StatelessWidget { + const _AttrRow({ + required this.rowHeight, + required this.blocks, + required this.model, + required this.scrollOffset, + required this.viewportWidth, + }); + + final double rowHeight; + final List<_ValueBlock> blocks; + final _TimelineModel model; + final double scrollOffset; + final double viewportWidth; + + @override + Widget build(BuildContext context) { + final width = math.max(model.axisTotalWidth, 120.0); + final activeBlock = _activeBlock(blocks, scrollOffset); + final double stickyWidth = activeBlock == null + ? 0 + : (activeBlock.right - scrollOffset).clamp(20.0, viewportWidth); + + return SizedBox( + width: width, + height: rowHeight, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + for (final block in blocks) + Positioned( + left: block.left, + width: block.width, + top: 0, + bottom: 0, + child: _ValueBlockView(block: block), + ), + if (activeBlock != null) + Positioned( + left: scrollOffset, + width: stickyWidth, + top: 0, + bottom: 0, + child: IgnorePointer( + child: ClipRect( + child: _ValueBlockView( + block: activeBlock.copyWith( + left: scrollOffset, + width: stickyWidth, + ), + clipLeftEdge: scrollOffset > activeBlock.left + 0.1, + ), + ), + ), + ), + ], + ), + ); + } + + _ValueBlock? _activeBlock(List<_ValueBlock> blocks, double offset) { + for (final block in blocks) { + if (offset >= block.left && offset < block.right) return block; + } + return null; + } +} + +class _ValueBlockView extends StatelessWidget { + const _ValueBlockView({ + required this.block, + this.clipLeftEdge = false, + }); + + final _ValueBlock block; + final bool clipLeftEdge; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = block.cell.color.withValues(alpha: 0.9); + final textColor = ThemeData.estimateBrightnessForColor(color) == + Brightness.dark + ? Colors.white + : Colors.black87; + + final radius = BorderRadius.only( + topLeft: Radius.circular(clipLeftEdge ? 0 : 12), + bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12), + topRight: const Radius.circular(12), + bottomRight: const Radius.circular(12), + ); + + return ClipRRect( + borderRadius: radius, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: block.cell.value.isEmpty + ? theme.colorScheme.surfaceContainerHighest + : color, + borderRadius: BorderRadius.zero, + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: block.cell.value.isEmpty + ? const SizedBox.shrink() + : FittedBox( + alignment: Alignment.topLeft, + fit: BoxFit.scaleDown, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 1, minHeight: 1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + block.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( + block.cell.rangeLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: textColor.withValues(alpha: 0.9), + ) ?? + TextStyle(color: textColor.withValues(alpha: 0.9)), + ), + ], + ), + ), + ), + ), + ); + } +} + +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; + final List boundaries; + final double axisTotalWidth; + + _TimelineModel({ + required this.axisSegments, + required this.attrRows, + required this.endLabel, + required this.boundaries, + required this.axisTotalWidth, + }); + + 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); + if (segments.isNotEmpty && segments.last.value == entry.valueLabel) { + final last = segments.removeLast(); + segments.add( + last.copyWith( + end: end.isAfter(last.end) ? end : last.end, + ), + ); + } else { + 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)); + final effectiveMaxEnd = maxEnd ?? now; + + final boundaryDates = {}; + for (final segments in attrSegments.values) { + for (final seg in segments) { + boundaryDates.add(seg.start); + boundaryDates.add(seg.end); + } + } + boundaryDates.add(effectiveMaxEnd); + var boundaries = boundaryDates.toList()..sort(); + if (boundaries.length < 2) { + boundaries = [minStart!, effectiveMaxEnd]; + } + + final axisSegments = <_AxisSegment>[]; + const double yearWidth = 240.0; + for (int i = 0; i < boundaries.length - 1; i++) { + final start = boundaries[i]; + final end = boundaries[i + 1]; + const width = yearWidth; + final double offset = axisSegments.isEmpty + ? 0.0 + : axisSegments.last.offset + axisSegments.last.width; + axisSegments.add( + _AxisSegment( + start: start, + end: end, + width: width, + offset: offset, + label: _formatDate(start) ?? '', + ), + ); + } + final axisTotalWidth = + axisSegments.fold(0, (sum, seg) => sum + seg.width); + + final attrRows = >{}; + for (final entry in attrSegments.entries) { + final blocks = <_ValueBlock>[]; + for (final seg in entry.value) { + final left = _positionForDate(seg.start, boundaries, axisSegments); + final right = _positionForDate(seg.end, boundaries, axisSegments); + final span = right - left; + final width = span < 2.0 ? 2.0 : span; + blocks.add( + _ValueBlock( + left: left, + width: width, + cell: _RowCell.fromSegment(seg), + ), + ); + } + attrRows[entry.key] = blocks; + } + + final endLabel = _formatDate(effectiveMaxEnd) ?? 'Now'; + return _TimelineModel( + axisSegments: axisSegments, + attrRows: attrRows, + endLabel: endLabel, + boundaries: boundaries, + axisTotalWidth: axisTotalWidth, + ); + } + + static double _positionForDate( + DateTime date, + List boundaries, + List<_AxisSegment> segments, + ) { + for (int i = 0; i < boundaries.length - 1; i++) { + final start = boundaries[i]; + final end = boundaries[i + 1]; + if (!date.isAfter(end)) { + final seg = segments[i]; + final span = end.difference(start).inMilliseconds; + final elapsed = date.difference(start).inMilliseconds.clamp(0, span); + if (span <= 0) return seg.offset; + final fraction = elapsed / span; + return seg.offset + (seg.width * fraction); + } + } + return segments.isNotEmpty + ? segments.last.offset + segments.last.width + : 0.0; + } +} + +class _AxisSegment { + final DateTime start; + final DateTime end; + final double width; + final double offset; + final String label; + + _AxisSegment({ + required this.start, + required this.end, + required this.width, + required this.offset, + 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, + }); + + bool overlaps(DateTime s, DateTime e) { + return start.isBefore(e) && end.isAfter(s); + } + + _ValueSegment copyWith({DateTime? start, DateTime? end, String? value}) { + return _ValueSegment( + start: start ?? this.start, + end: end ?? this.end, + value: value ?? this.value, + entry: entry, + ); + } +} + +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) ?? ''; + return _RowCell( + value: seg.value, + rangeLabel: displayStart, + color: _colorForValue(seg.value), + ); + } +} + +class _ValueBlock { + final double left; + final double width; + final _RowCell cell; + + const _ValueBlock({ + required this.left, + required this.width, + required this.cell, + }); + + double get right => left + width; + + _ValueBlock copyWith({ + double? left, + double? width, + _RowCell? cell, + }) { + return _ValueBlock( + left: left ?? this.left, + width: width ?? this.width, + cell: cell ?? this.cell, + ); + } +} + +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/new_entry.dart b/lib/components/pages/new_entry.dart index c6ab084..033078d 100644 --- a/lib/components/pages/new_entry.dart +++ b/lib/components/pages/new_entry.dart @@ -8,8 +8,8 @@ import 'package:intl/intl.dart'; import 'package:mileograph_flutter/components/calculator/calculator.dart'; import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/objects/objects.dart'; -import 'package:mileograph_flutter/services/apiService.dart'; -import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:mileograph_flutter/services/api_service.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -131,7 +131,7 @@ class _NewEntryPageState extends State { final controller = TextEditingController(); final result = await showDialog( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: const Text('New Trip'), content: TextField( controller: controller, @@ -140,25 +140,28 @@ class _NewEntryPageState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), ElevatedButton( - onPressed: () => Navigator.of(context).pop(controller.text.trim()), + onPressed: () => Navigator.of(dialogContext).pop(controller.text.trim()), child: const Text('Add'), ), ], ), ); - if (!mounted) return; + if (!context.mounted) { + controller.dispose(); + return; + } if (result != null && result.isNotEmpty) { final api = context.read(); final data = context.read(); - final messenger = ScaffoldMessenger.of(context); + final messenger = ScaffoldMessenger.maybeOf(context); try { await api.put('/trips/new', {"trip_name": result}); await data.fetchTrips(); - if (!mounted) return; + if (!context.mounted) return; final trips = data.tripList; final match = trips.firstWhere( (t) => t.tripName == result, @@ -169,11 +172,15 @@ class _NewEntryPageState extends State { setState(() => _selectedTripId = match.tripId); _saveDraft(); } catch (e) { - if (!mounted) return; - messenger.showSnackBar( + if (!context.mounted) return; + messenger?.showSnackBar( SnackBar(content: Text('Failed to add trip: $e')), ); + } finally { + controller.dispose(); } + } else { + controller.dispose(); } } @@ -584,8 +591,11 @@ class _NewEntryPageState extends State { final confirmed = await _confirmDuplicateSubmission(); if (!confirmed) return; } - setState(() => _submitting = true); + if (!mounted) return; final api = context.read(); + final dataService = context.read(); + final messenger = ScaffoldMessenger.maybeOf(context); + setState(() => _submitting = true); final isEditingExisting = _isEditing && widget.editLegId != null; try { @@ -626,9 +636,9 @@ class _NewEntryPageState extends State { } } if (!mounted) return; - context.read().refreshLegs(); + dataService.refreshLegs(); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( + messenger?.showSnackBar( SnackBar( content: Text( isEditingExisting ? 'Entry updated' : 'Entry submitted', @@ -639,9 +649,9 @@ class _NewEntryPageState extends State { _activeDraftId = null; } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Failed to submit: $e'))); + messenger?.showSnackBar( + SnackBar(content: Text('Failed to submit: $e')), + ); } finally { if (mounted) setState(() => _submitting = false); } @@ -1279,8 +1289,15 @@ class _NewEntryPageState extends State { ); } - return WillPopScope( - onWillPop: () => _handleExitIntent(), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + final allow = await _handleExitIntent(); + if (allow && context.mounted) { + Navigator.of(context).maybePop(); + } + }, child: Scaffold( appBar: _isEditing ? AppBar( @@ -1288,7 +1305,7 @@ class _NewEntryPageState extends State { icon: const Icon(Icons.arrow_back), onPressed: () async { if (!await _handleExitIntent()) return; - if (!mounted) return; + if (!context.mounted) return; Navigator.of(context).maybePop(); }, ), @@ -1507,13 +1524,13 @@ class _DraftListBody extends StatefulWidget { } class _DraftListBodyState extends State<_DraftListBody> { - late List<_StoredDraft> _drafts = widget.drafts; + late final List<_StoredDraft> _drafts = List.of(widget.drafts); @override Widget build(BuildContext context) { return ListView.separated( itemCount: _drafts.length, - separatorBuilder: (_, __) => const Divider(height: 0), + separatorBuilder: (context, _) => const Divider(height: 0), itemBuilder: (context, index) { final draft = _drafts[index]; final routeLine = _draftSubtitle(draft); diff --git a/lib/components/pages/new_traction.dart b/lib/components/pages/new_traction.dart index b65ab10..1bf8f25 100644 --- a/lib/components/pages/new_traction.dart +++ b/lib/components/pages/new_traction.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; enum _SpeedUnit { kph, mph } diff --git a/lib/components/pages/traction.dart b/lib/components/pages/traction.dart index eed012b..5a11bc3 100644 --- a/lib/components/pages/traction.dart +++ b/lib/components/pages/traction.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:mileograph_flutter/components/traction/traction_card.dart'; import 'package:mileograph_flutter/objects/objects.dart'; -import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; class TractionPage extends StatefulWidget { @@ -452,7 +453,17 @@ class _TractionPageState extends State { else Column( children: [ - ...traction.map((loco) => _buildTractionCard(context, loco)), + ...traction.map( + (loco) => TractionCard( + loco: loco, + selectionMode: widget.selectionMode, + isSelected: _isSelected(loco), + onShowInfo: () => showTractionDetails(context, loco), + onOpenTimeline: () => _openTimeline(loco), + onToggleSelect: + widget.selectionMode ? () => _toggleSelection(loco) : null, + ), + ), if (data.tractionHasMore || data.isTractionLoading) Padding( padding: const EdgeInsets.only(top: 8.0), @@ -476,9 +487,9 @@ class _TractionPageState extends State { ), if (data.isTractionLoading) Positioned.fill( - child: IgnorePointer( - child: Container( - color: Theme.of(context).colorScheme.surface.withOpacity(0.6), + child: IgnorePointer( + child: Container( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), child: const Center(child: CircularProgressIndicator()), ), ), @@ -517,196 +528,23 @@ class _TractionPageState extends State { return listView; } - Widget _buildTractionCard(BuildContext context, LocoSummary loco) { + void _toggleSelection(LocoSummary loco) { final keyVal = '${loco.locoClass}-${loco.number}'; - final isSelected = _selectedKeys.contains(keyVal); - final status = loco.status ?? 'Unknown'; - final operatorName = loco.operator ?? ''; - final domain = loco.domain ?? ''; - final hasMileageOrTrips = _hasMileageOrTrips(loco); - final statusColors = _statusChipColors(context, status); - return Card( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - loco.number, - style: Theme.of(context).textTheme.headlineSmall - ?.copyWith(fontWeight: FontWeight.w800), - ), - if (hasMileageOrTrips) - Padding( - padding: const EdgeInsets.only(left: 6.0), - child: Icon( - Icons.check_circle, - size: 18, - color: Colors.green.shade600, - ), - ), - ], - ), - Text( - loco.locoClass, - style: Theme.of(context).textTheme.labelMedium, - ), - if ((loco.name ?? '').isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 2.0), - child: Text( - loco.name ?? '', - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontStyle: FontStyle.italic), - ), - ), - ], - ), - Chip( - label: Text(status), - backgroundColor: statusColors.$1, - labelStyle: TextStyle(color: statusColors.$2), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - TextButton.icon( - onPressed: () => _showLocoInfo(loco), - 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( - onPressed: () { - if (widget.onSelect != null) { - widget.onSelect!(loco); - } - setState(() { - if (isSelected) { - _selectedKeys.remove(keyVal); - } else { - _selectedKeys.add(keyVal); - } - }); - }, - icon: Icon( - isSelected - ? Icons.remove_circle_outline - : Icons.add_circle_outline, - ), - label: Text(isSelected ? 'Remove' : 'Add to entry'), - ), - ], - ), - Wrap( - spacing: 8, - runSpacing: 4, - children: [ - _statPill( - context, - label: 'Miles', - value: _formatNumber(loco.mileage), - ), - _statPill( - context, - label: 'Trips', - value: (loco.trips ?? loco.journeys ?? 0).toString(), - ), - if (operatorName.isNotEmpty) - _statPill(context, label: 'Operator', value: operatorName), - if (domain.isNotEmpty) - _statPill(context, label: 'Domain', value: domain), - ], - ), - ], - ), - ), - ); + if (widget.onSelect != null) { + widget.onSelect!(loco); + } + setState(() { + if (_selectedKeys.contains(keyVal)) { + _selectedKeys.remove(keyVal); + } else { + _selectedKeys.add(keyVal); + } + }); } - Widget _statPill( - BuildContext context, { - required String label, - required String value, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('$label: ', style: Theme.of(context).textTheme.labelSmall), - Text( - value, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700), - ), - ], - ), - ); - } - - (Color, Color) _statusChipColors(BuildContext context, String status) { - final scheme = Theme.of(context).colorScheme; - final isDark = scheme.brightness == Brightness.dark; - Color blend( - Color base, { - double bgOpacity = 0.18, - double fgOpacity = 0.82, - }) { - final bg = Color.alphaBlend( - base.withOpacity(isDark ? bgOpacity + 0.07 : bgOpacity), - scheme.surface, - ); - final fg = Color.alphaBlend( - base.withOpacity(isDark ? fgOpacity : fgOpacity * 0.8), - scheme.onSurface, - ); - return Color.lerp(bg, fg, 0.0) ?? bg; - } - - Color background; - Color foreground; - final key = status.toLowerCase(); - - if (key.contains('scrap')) { - background = blend(Colors.red); - foreground = Colors.red.shade200.withOpacity(isDark ? 0.85 : 0.9); - } else if (key.contains('active')) { - background = blend(scheme.primary); - foreground = scheme.primary.withOpacity(isDark ? 0.9 : 0.8); - } else if (key.contains('withdrawn')) { - background = blend(Colors.amber); - foreground = Colors.amber.shade800.withOpacity(isDark ? 0.9 : 0.8); - } else if (key.contains('stored') || key.contains('unknown')) { - background = blend(Colors.grey); - foreground = Colors.grey.shade700.withOpacity(isDark ? 0.85 : 0.75); - } else { - background = scheme.surfaceContainerHighest; - foreground = scheme.onSurface; - } - return (background, foreground); + bool _isSelected(LocoSummary loco) { + final keyVal = '${loco.locoClass}-${loco.number}'; + return _selectedKeys.contains(keyVal); } void _openTimeline(LocoSummary loco) { @@ -717,130 +555,6 @@ class _TractionPageState extends State { ); } - Future _showLocoInfo(LocoSummary loco) async { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (ctx) { - return DraggableScrollableSheet( - expand: false, - maxChildSize: 0.9, - initialChildSize: 0.65, - builder: (_, controller) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.of(ctx).pop(), - ), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - loco.number, - style: Theme.of(context).textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.w800), - ), - if (_hasMileageOrTrips(loco)) - Padding( - padding: const EdgeInsets.only(left: 6.0), - child: Icon( - Icons.check_circle, - size: 18, - color: Colors.green.shade600, - ), - ), - ], - ), - Text( - loco.locoClass, - style: Theme.of(context).textTheme.labelMedium, - ), - ], - ), - ], - ), - if ((loco.name ?? '').isNotEmpty) - Padding( - padding: const EdgeInsets.only(left: 52.0, bottom: 12), - child: Text( - loco.name ?? '', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - const SizedBox(height: 4), - Expanded( - child: ListView( - controller: controller, - children: [ - _detailRow('Status', loco.status ?? 'Unknown'), - _detailRow('Operator', loco.operator ?? ''), - _detailRow('Domain', loco.domain ?? ''), - _detailRow('Owner', loco.owner ?? ''), - _detailRow('Livery', loco.livery ?? ''), - _detailRow('Location', loco.location ?? ''), - _detailRow('Mileage', _formatNumber(loco.mileage ?? 0)), - _detailRow( - 'Trips', - (loco.trips ?? loco.journeys ?? 0).toString(), - ), - _detailRow('EVN', loco.evn ?? ''), - if (loco.notes != null && loco.notes!.isNotEmpty) - _detailRow('Notes', loco.notes!), - ], - ), - ), - ], - ), - ); - }, - ); - }, - ); - } - - Widget _detailRow(String label, String value) { - if (value.isEmpty) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6.0), - child: Row( - children: [ - SizedBox( - width: 110, - child: Text( - label, - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600), - ), - ), - Expanded( - child: Text(value, style: Theme.of(context).textTheme.bodyMedium), - ), - ], - ), - ); - } - - String _formatNumber(double? value) { - if (value == null) return '0'; - return value.toStringAsFixed(1); - } - - bool _hasMileageOrTrips(LocoSummary loco) { - final mileage = loco.mileage ?? 0; - final trips = loco.trips ?? loco.journeys ?? 0; - return mileage > 0 || trips > 0; - } - Widget _buildFilterInput( BuildContext context, EventField field, diff --git a/lib/components/pages/trips.dart b/lib/components/pages/trips.dart index 638cae4..f0b37e1 100644 --- a/lib/components/pages/trips.dart +++ b/lib/components/pages/trips.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/objects/objects.dart'; -import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; class TripsPage extends StatefulWidget { diff --git a/lib/components/traction/traction_card.dart b/lib/components/traction/traction_card.dart new file mode 100644 index 0000000..915390f --- /dev/null +++ b/lib/components/traction/traction_card.dart @@ -0,0 +1,336 @@ +import 'package:flutter/material.dart'; +import 'package:mileograph_flutter/objects/objects.dart'; + +class TractionCard extends StatelessWidget { + const TractionCard({ + super.key, + required this.loco, + required this.selectionMode, + required this.isSelected, + required this.onShowInfo, + required this.onOpenTimeline, + this.onToggleSelect, + }); + + final LocoSummary loco; + final bool selectionMode; + final bool isSelected; + final VoidCallback onShowInfo; + final VoidCallback onOpenTimeline; + final VoidCallback? onToggleSelect; + + @override + Widget build(BuildContext context) { + final status = loco.status ?? 'Unknown'; + final operatorName = loco.operator ?? ''; + final domain = loco.domain ?? ''; + final hasMileageOrTrips = _hasMileageOrTrips(loco); + final statusColors = _statusChipColors(context, status); + + return Card( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + loco.number, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w800), + ), + if (hasMileageOrTrips) + Padding( + padding: const EdgeInsets.only(left: 6.0), + child: Icon( + Icons.check_circle, + size: 18, + color: Colors.green.shade600, + ), + ), + ], + ), + Text( + loco.locoClass, + style: Theme.of(context).textTheme.labelMedium, + ), + if ((loco.name ?? '').isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Text( + loco.name ?? '', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontStyle: FontStyle.italic), + ), + ), + ], + ), + Chip( + label: Text(status), + backgroundColor: statusColors.$1, + labelStyle: TextStyle(color: statusColors.$2), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton.icon( + onPressed: onShowInfo, + icon: const Icon(Icons.info_outline), + label: const Text('Details'), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: onOpenTimeline, + icon: const Icon(Icons.timeline), + label: const Text('Timeline'), + ), + const Spacer(), + if (selectionMode && onToggleSelect != null) + TextButton.icon( + onPressed: onToggleSelect, + icon: Icon( + isSelected + ? Icons.remove_circle_outline + : Icons.add_circle_outline, + ), + label: Text(isSelected ? 'Remove' : 'Add to entry'), + ), + ], + ), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + _statPill( + context, + label: 'Miles', + value: _formatNumber(loco.mileage), + ), + _statPill( + context, + label: 'Trips', + value: (loco.trips ?? loco.journeys ?? 0).toString(), + ), + if (operatorName.isNotEmpty) + _statPill(context, label: 'Operator', value: operatorName), + if (domain.isNotEmpty) + _statPill(context, label: 'Domain', value: domain), + ], + ), + ], + ), + ), + ); + } + + Widget _statPill( + BuildContext context, { + required String label, + required String value, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('$label: ', style: Theme.of(context).textTheme.labelSmall), + Text( + value, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + ); + } +} + +Future showTractionDetails( + BuildContext context, + LocoSummary loco, +) async { + final hasMileageOrTrips = _hasMileageOrTrips(loco); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) { + return DraggableScrollableSheet( + expand: false, + maxChildSize: 0.9, + initialChildSize: 0.65, + builder: (_, controller) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(ctx).pop(), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + loco.number, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w800), + ), + if (hasMileageOrTrips) + Padding( + padding: const EdgeInsets.only(left: 6.0), + child: Icon( + Icons.check_circle, + size: 18, + color: Colors.green.shade600, + ), + ), + ], + ), + Text( + loco.locoClass, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + ], + ), + if ((loco.name ?? '').isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 52.0, bottom: 12), + child: Text( + loco.name ?? '', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(height: 4), + Expanded( + child: ListView( + controller: controller, + children: [ + _detailRow(context, 'Status', loco.status ?? 'Unknown'), + _detailRow(context, 'Operator', loco.operator ?? ''), + _detailRow(context, 'Domain', loco.domain ?? ''), + _detailRow(context, 'Owner', loco.owner ?? ''), + _detailRow(context, 'Livery', loco.livery ?? ''), + _detailRow(context, 'Location', loco.location ?? ''), + _detailRow( + context, + 'Mileage', + _formatNumber(loco.mileage ?? 0), + ), + _detailRow( + context, + 'Trips', + (loco.trips ?? loco.journeys ?? 0).toString(), + ), + _detailRow(context, 'EVN', loco.evn ?? ''), + if (loco.notes != null && loco.notes!.isNotEmpty) + _detailRow(context, 'Notes', loco.notes!), + ], + ), + ), + ], + ), + ); + }, + ); + }, + ); +} + +Widget _detailRow(BuildContext context, String label, String value) { + if (value.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + children: [ + SizedBox( + width: 110, + child: Text( + label, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + Expanded( + child: Text(value, style: Theme.of(context).textTheme.bodyMedium), + ), + ], + ), + ); +} + +(Color, Color) _statusChipColors(BuildContext context, String status) { + final scheme = Theme.of(context).colorScheme; + final isDark = scheme.brightness == Brightness.dark; + Color blend( + Color base, { + double bgOpacity = 0.18, + double fgOpacity = 0.82, + }) { + final bg = Color.alphaBlend( + base.withValues(alpha: isDark ? bgOpacity + 0.07 : bgOpacity), + scheme.surface, + ); + final fg = Color.alphaBlend( + base.withValues(alpha: isDark ? fgOpacity : fgOpacity * 0.8), + scheme.onSurface, + ); + return Color.lerp(bg, fg, 0.0) ?? bg; + } + + Color background; + Color foreground; + final key = status.toLowerCase(); + + if (key.contains('scrap')) { + background = blend(Colors.red); + foreground = Colors.red.shade200.withValues(alpha: isDark ? 0.85 : 0.9); + } else if (key.contains('active')) { + background = blend(scheme.primary); + foreground = scheme.primary.withValues(alpha: isDark ? 0.9 : 0.8); + } else if (key.contains('withdrawn')) { + background = blend(Colors.amber); + foreground = Colors.amber.shade800.withValues(alpha: isDark ? 0.9 : 0.8); + } else if (key.contains('stored') || key.contains('unknown')) { + background = blend(Colors.grey); + foreground = Colors.grey.shade700.withValues(alpha: isDark ? 0.85 : 0.75); + } else { + background = scheme.surfaceContainerHighest; + foreground = scheme.onSurface; + } + return (background, foreground); +} + +bool _hasMileageOrTrips(LocoSummary loco) { + final mileage = loco.mileage ?? 0; + final trips = loco.trips ?? loco.journeys ?? 0; + return mileage > 0 || trips > 0; +} + +String _formatNumber(double? value) { + if (value == null) return '0'; + return value.toStringAsFixed(1); +} diff --git a/lib/main.dart b/lib/main.dart index dc6c213..3ba6a69 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,9 +9,9 @@ import 'package:mileograph_flutter/components/pages/trips.dart'; import 'package:provider/provider.dart'; import 'package:mileograph_flutter/components/pages/legs.dart'; -import 'package:mileograph_flutter/services/apiService.dart'; +import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/authservice.dart'; -import 'package:mileograph_flutter/services/dataService.dart'; +import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'components/login/login.dart'; @@ -38,7 +38,7 @@ void main() { }, ), ProxyProvider( - update: (_, auth, __) { + update: (_, auth, previous) { api.setTokenProvider(() => auth.token); api.setUnauthorizedHandler(() => auth.handleTokenExpired()); }, @@ -88,14 +88,17 @@ class MyApp extends StatelessWidget { return MyHomePage(child: child); }, routes: [ - GoRoute(path: '/', builder: (_, __) => const Dashboard()), - GoRoute(path: '/calculator', builder: (_, __) => CalculatorPage()), + GoRoute(path: '/', builder: (context, state) => const Dashboard()), + GoRoute( + path: '/calculator', + builder: (context, state) => CalculatorPage(), + ), GoRoute( path: '/calculator/details', - builder: (_, __) => CalculatorPage(), + builder: (context, state) => CalculatorPage(), ), - GoRoute(path: '/legs', builder: (_, __) => LegsPage()), - GoRoute(path: '/traction', builder: (_, __) => TractionPage()), + GoRoute(path: '/legs', builder: (context, state) => LegsPage()), + GoRoute(path: '/traction', builder: (context, state) => TractionPage()), GoRoute( path: '/traction/:id/timeline', builder: (_, state) { @@ -119,10 +122,10 @@ class MyApp extends StatelessWidget { ), GoRoute( path: '/traction/new', - builder: (_, __) => const NewTractionPage(), + builder: (context, state) => const NewTractionPage(), ), - GoRoute(path: '/trips', builder: (_, __) => TripsPage()), - GoRoute(path: '/add', builder: (_, __) => NewEntryPage()), + GoRoute(path: '/trips', builder: (context, state) => TripsPage()), + GoRoute(path: '/add', builder: (context, state) => NewEntryPage()), GoRoute( path: '/legs/edit/:id', builder: (_, state) { @@ -133,7 +136,7 @@ class MyApp extends StatelessWidget { ), ], ), - GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), + GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), ], ); @@ -232,6 +235,7 @@ class _MyHomePageState extends State { _fetched = true; WidgetsBinding.instance.addPostFrameCallback((_) { Future(() async { + if (!mounted) return; final data = context.read(); final auth = context.read(); api.setTokenProvider(() => auth.token); diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index 808da0c..563d1e2 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -16,24 +16,24 @@ class DestinationObject { } class UserData { - const UserData(this.username, this.full_name, this.user_id, this.email); + const UserData(this.username, this.fullName, this.userId, this.email); - final String user_id; + final String userId; final String username; - final String full_name; + final String fullName; final String email; } class AuthenticatedUserData extends UserData { const AuthenticatedUserData({ - required String user_id, + required String userId, required String username, - required String full_name, + required String fullName, required String email, - required this.access_token, - }) : super(username, full_name, user_id, email); + required this.accessToken, + }) : super(username, fullName, userId, email); - final String access_token; + final String accessToken; } class HomepageStats { diff --git a/lib/services/apiService.dart b/lib/services/api_service.dart similarity index 100% rename from lib/services/apiService.dart rename to lib/services/api_service.dart diff --git a/lib/services/authservice.dart b/lib/services/authservice.dart index 1b5f572..b99cd57 100644 --- a/lib/services/authservice.dart +++ b/lib/services/authservice.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:mileograph_flutter/objects/objects.dart'; -import 'package:mileograph_flutter/services/apiService.dart'; -import 'package:mileograph_flutter/services/tokenStorageService.dart'; +import 'package:mileograph_flutter/services/api_service.dart'; +import 'package:mileograph_flutter/services/token_storage_service.dart'; class AuthService extends ChangeNotifier { final ApiService api; @@ -14,10 +14,10 @@ class AuthService extends ChangeNotifier { AuthenticatedUserData? _user; bool get isLoggedIn => _user != null; - String? get token => _user?.access_token; - String? get userId => _user?.user_id; + String? get token => _user?.accessToken; + String? get userId => _user?.userId; String? get username => _user?.username; - String? get fullName => _user?.full_name; + String? get fullName => _user?.fullName; void setLoginData({ required String userId, @@ -27,10 +27,10 @@ class AuthService extends ChangeNotifier { required String email, }) { _user = AuthenticatedUserData( - user_id: userId, + userId: userId, username: username, - full_name: fullName, - access_token: accessToken, + fullName: fullName, + accessToken: accessToken, email: email, ); _persistToken(accessToken); diff --git a/lib/services/dataService.dart b/lib/services/data_service.dart similarity index 99% rename from lib/services/dataService.dart rename to lib/services/data_service.dart index c366705..fc23536 100644 --- a/lib/services/dataService.dart +++ b/lib/services/data_service.dart @@ -4,7 +4,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:mileograph_flutter/objects/objects.dart'; -import 'package:mileograph_flutter/services/apiService.dart'; // assumes you've moved HomepageStats + submodels to a separate file +import 'package:mileograph_flutter/services/api_service.dart'; // assumes you've moved HomepageStats + submodels to a separate file class _LegFetchOptions { final int limit; diff --git a/lib/services/tokenStorageService.dart b/lib/services/token_storage_service.dart similarity index 100% rename from lib/services/tokenStorageService.dart rename to lib/services/token_storage_service.dart diff --git a/pubspec.lock b/pubspec.lock index 74e88d9..f16bc09 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -66,7 +66,7 @@ packages: source: hosted version: "1.1.2" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" diff --git a/pubspec.yaml b/pubspec.yaml index 462dda2..9345fc0 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.2.0+1 +version: 0.2.1+1 environment: sdk: ^3.8.1 @@ -36,6 +36,7 @@ dependencies: provider: ^6.1.5 dynamic_color: ^1.6.6 flutter_secure_storage: ^10.0.0 + collection: ^1.18.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.