7 Commits

Author SHA1 Message Date
80be797322 major refactor
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m36s
Release / android-build (push) Successful in 15m50s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 25s
2025-12-16 16:49:39 +00:00
4a6aee8a15 add event update panel
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m41s
Release / android-build (push) Successful in 15m19s
Release / release-master (push) Successful in 22s
Release / release-dev (push) Successful in 24s
2025-12-16 16:14:14 +00:00
411e82807b attempt to add loco search indicator
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 6m46s
Release / android-build (push) Successful in 15m22s
Release / release-master (push) Successful in 21s
Release / release-dev (push) Successful in 23s
2025-12-16 12:47:52 +00:00
2b4d2623fc add loco timeline view 2025-12-16 12:24:53 +00:00
80c315866f add timeline
Some checks failed
Release / meta (push) Successful in 9s
Release / linux-build (push) Failing after 6m22s
Release / android-build (push) Failing after 14m39s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2025-12-15 16:02:21 +00:00
da70dce369 drafts minor changes, edit minor changes
All checks were successful
Release / meta (push) Successful in 15s
Release / linux-build (push) Successful in 9m20s
Release / android-build (push) Successful in 25m33s
Release / release-master (push) Successful in 43s
Release / release-dev (push) Successful in 45s
2025-12-15 00:33:18 +00:00
603e117af8 add draft changes
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m18s
Release / android-build (push) Successful in 16m30s
Release / release-master (push) Successful in 24s
Release / release-dev (push) Successful in 27s
2025-12-14 23:30:45 +00:00
24 changed files with 2934 additions and 547 deletions

View File

@@ -19,6 +19,7 @@ jobs:
- mileograph - mileograph
outputs: outputs:
base_version: ${{ steps.meta.outputs.base }} base_version: ${{ steps.meta.outputs.base }}
release_tag: ${{ steps.meta.outputs.release_tag }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -29,6 +30,7 @@ jobs:
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml) RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
BASE_VERSION=${RAW_VERSION%%+*} BASE_VERSION=${RAW_VERSION%%+*}
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT" echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=v${BASE_VERSION}" >> "$GITHUB_OUTPUT"
android-build: android-build:
runs-on: runs-on:
@@ -233,7 +235,7 @@ jobs:
id: bundle id: bundle
run: | run: |
BASE="${{ needs.meta.outputs.base_version }}" 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" mv "artifacts/mileograph-${BASE}.apk" "artifacts/mileograph-${BASE}-dev.apk"
@@ -245,7 +247,7 @@ jobs:
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1
with: with:
tag: ${{ steps.bundle.outputs.tag }} tag: ${{ steps.bundle.outputs.tag }}
name: v${{ needs.meta.outputs.base_version }}-dev build ${{ github.run_number }} name: ${{ steps.bundle.outputs.tag }}
prerelease: true prerelease: true
commit: ${{ github.sha }} commit: ${{ github.sha }}
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.GITEA_TOKEN }}
@@ -308,7 +310,7 @@ jobs:
id: bundle id: bundle
run: | run: |
BASE="${{ needs.meta.outputs.base_version }}" BASE="${{ needs.meta.outputs.base_version }}"
TAG="v${BASE}" TAG="${{ needs.meta.outputs.release_tag }}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT" echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT"

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/apiService.dart'; import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import './routeSummaryWidget.dart'; import './route_summary_widget.dart';
class StationAutocomplete extends StatefulWidget { class StationAutocomplete extends StatefulWidget {
const StationAutocomplete({ const StationAutocomplete({
@@ -97,10 +97,16 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
} }
class RouteCalculator extends StatefulWidget { class RouteCalculator extends StatefulWidget {
const RouteCalculator({super.key, this.onDistanceComputed, this.onApplyRoute}); const RouteCalculator({
super.key,
this.onDistanceComputed,
this.onApplyRoute,
this.initialStations,
});
final ValueChanged<double>? onDistanceComputed; final ValueChanged<double>? onDistanceComputed;
final ValueChanged<RouteResult>? onApplyRoute; final ValueChanged<RouteResult>? onApplyRoute;
final List<String>? initialStations;
@override @override
State<RouteCalculator> createState() => _RouteCalculatorState(); State<RouteCalculator> createState() => _RouteCalculatorState();
@@ -122,6 +128,9 @@ class _RouteCalculatorState extends State<RouteCalculator> {
super.didChangeDependencies(); super.didChangeDependencies();
if (!_fetched) { if (!_fetched) {
_fetched = true; _fetched = true;
if (widget.initialStations != null && widget.initialStations!.isNotEmpty) {
context.read<DataService>().stations = List.from(widget.initialStations!);
}
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final result = await data.fetchStations(); final result = await data.fetchStations();

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; 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'; import 'package:provider/provider.dart';

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; 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'; import 'package:provider/provider.dart';

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/dashboard/leaderboardPanel.dart'; import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart';
import 'package:mileograph_flutter/components/dashboard/topTractionPanel.dart'; import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class Dashboard extends StatefulWidget { class Dashboard extends StatefulWidget {
@@ -78,7 +78,7 @@ class _DashboardState extends State<Dashboard> {
child: Container( child: Container(
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.surface.withOpacity(0.7), ).colorScheme.surface.withValues(alpha: 0.7),
child: const Center( child: const Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -105,7 +105,7 @@ class _DashboardState extends State<Dashboard> {
bool loading, bool loading,
) { ) {
final greetingName = final greetingName =
stats?.user?.full_name ?? auth.fullName ?? auth.username ?? 'there'; stats?.user?.fullName ?? auth.fullName ?? auth.username ?? 'there';
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -154,7 +154,7 @@ class _DashboardState extends State<Dashboard> {
label.toUpperCase(), label.toUpperCase(),
style: textTheme.labelSmall?.copyWith( style: textTheme.labelSmall?.copyWith(
letterSpacing: 0.7, letterSpacing: 0.7,
color: textTheme.bodySmall?.color?.withOpacity(0.7), color: textTheme.bodySmall?.color?.withValues(alpha: 0.7),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),

View File

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

View File

@@ -0,0 +1,263 @@
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/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,
required this.locoId,
required this.locoLabel,
});
final int locoId;
final String locoLabel;
@override
State<LocoTimelinePage> createState() => _LocoTimelinePageState();
}
class _LocoTimelinePageState extends State<LocoTimelinePage> {
final List<_EventDraft> _draftEvents = [];
bool _isSaving = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
@override
void dispose() {
_disposeDrafts(_draftEvents);
super.dispose();
}
Future<void> _load() {
final data = context.read<DataService>();
data.fetchEventFields();
return data.fetchLocoTimeline(widget.locoId);
}
void _addDraftEvent() {
setState(() {
_draftEvents.add(_EventDraft());
});
}
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<void> _saveEvents() async {
if (_isSaving) return;
if (!_canSaveDrafts()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please fix validation issues before saving.')),
);
return;
}
final data = context.read<DataService>();
setState(() {
_isSaving = true;
});
try {
final invalid = <String>[];
for (final draft in _draftEvents) {
final dateStr = draft.dateController.text.trim();
if (!_isValidDateString(dateStr)) {
invalid.add('Date is invalid (${dateStr.isEmpty ? 'empty' : dateStr})');
continue;
}
if (draft.fields.isEmpty) {
invalid.add('Add at least one field for each event');
continue;
}
final values = <String, dynamic>{};
for (final field in draft.fields) {
final val = field.value;
final isBlankString = val is String && val.trim().isEmpty;
if (val == null || isBlankString) {
invalid.add('Field ${field.field.display} is empty');
break;
}
values[field.field.name] = val;
}
if (invalid.isNotEmpty) continue;
if (values.isEmpty) {
invalid.add('Add at least one value');
continue;
}
await data.createLocoEvent(
locoId: widget.locoId,
eventDate: dateStr,
values: values,
details: draft.details,
);
}
if (invalid.isNotEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(invalid.first)),
);
}
return;
}
_disposeDrafts(_draftEvents);
_draftEvents.clear();
await _load();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Events saved')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to save events: $e')),
);
}
} finally {
if (mounted) {
setState(() {
_isSaving = false;
});
}
}
}
bool _isValidDateString(String input) {
final trimmed = input.trim();
final regex = RegExp(r'^\d{4}-(\d{2}|xx|XX)-(\d{2}|xx|XX)$');
if (!regex.hasMatch(trimmed)) return false;
final parts = trimmed.split('-');
final monthPart = parts[1];
final dayPart = parts[2];
final monthUnknown = monthPart.toLowerCase() == 'xx';
final dayUnknown = dayPart.toLowerCase() == 'xx';
if (monthUnknown && !dayUnknown) return false;
if (!monthUnknown) {
final month = int.tryParse(monthPart);
if (month == null || month < 1 || month > 12) return false;
}
if (!dayUnknown) {
final day = int.tryParse(dayPart);
if (day == null || day < 1 || day > 31) return false;
}
return true;
}
bool _draftIsValid(_EventDraft draft) {
final dateStr = draft.dateController.text.trim();
if (!_isValidDateString(dateStr)) return false;
if (draft.fields.isEmpty) return false;
for (final field in draft.fields) {
final val = field.value;
if (val == null) return false;
if (val is String && val.trim().isEmpty) return false;
}
return true;
}
bool _canSaveDrafts() {
if (_draftEvents.isEmpty) return false;
return _draftEvents.every(_draftIsValid);
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final timeline = data.timelineForLoco(widget.locoId);
final isLoading = data.isLocoTimelineLoading(widget.locoId);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: Text('Timeline · ${widget.locoLabel}'),
),
body: RefreshIndicator(
onRefresh: _load,
child: LayoutBuilder(
builder: (context, constraints) {
if (isLoading && timeline.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (timeline.isEmpty) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: 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'),
),
],
),
),
),
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
_TimelineGrid(
entries: timeline,
),
const SizedBox(height: 16),
_EventEditor(
eventFields: data.eventFields,
drafts: _draftEvents,
onAddEvent: _addDraftEvent,
onChange: () => setState(() {}),
onSave: _saveEvents,
onRemoveDraft: _removeDraftAt,
isSaving: _isSaving,
canSave: _canSaveDrafts(),
),
],
);
},
),
),
);
}
}

View File

@@ -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<EventField> eventFields;
final List<_EventDraft> drafts;
final VoidCallback onAddEvent;
final VoidCallback onChange;
final Future<void> 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<EventField> 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<EventField>(
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<dynamic> onChanged;
@override
Widget build(BuildContext context) {
if (field.enumValues != null && field.enumValues!.isNotEmpty) {
final options = field.enumValues!;
return DropdownButtonFormField<String>(
value: value is String && options.contains(value) ? value : null,
decoration: const InputDecoration(border: OutlineInputBorder()),
items: options
.map((v) => DropdownMenuItem<String>(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<bool>(
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});
}

View File

@@ -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<LocoAttrVersion> 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<String, List<_ValueBlock>> attrRows;
final String endLabel;
final List<DateTime> boundaries;
final double axisTotalWidth;
_TimelineModel({
required this.axisSegments,
required this.attrRows,
required this.endLabel,
required this.boundaries,
required this.axisTotalWidth,
});
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) {
final grouped = <String, List<LocoAttrVersion>>{};
for (final entry in entries) {
grouped.putIfAbsent(entry.attrCode, () => []).add(entry);
}
final now = DateTime.now();
DateTime? minStart;
DateTime? maxEnd;
final attrSegments = <String, List<_ValueSegment>>{};
grouped.forEach((attr, items) {
items.sort(
(a, b) => (_effectiveStart(a) ?? now)
.compareTo(_effectiveStart(b) ?? now),
);
final segments = <_ValueSegment>[];
for (int i = 0; i < items.length; i++) {
final entry = items[i];
final start = _effectiveStart(entry) ?? now;
final nextStart = i < items.length - 1
? _effectiveStart(items[i + 1])
: null;
final rawEnd = entry.validTo ?? nextStart ?? now;
final end = _safeEnd(start, rawEnd);
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 = <DateTime>{};
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<double>(0, (sum, seg) => sum + seg.width);
final attrRows = <String, List<_ValueBlock>>{};
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<DateTime> 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();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.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 } enum _SpeedUnit { kph, mph }

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/objects/objects.dart';
import 'package:mileograph_flutter/services/dataService.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class TractionPage extends StatefulWidget { class TractionPage extends StatefulWidget {
@@ -423,12 +424,12 @@ class _TractionPageState extends State<TractionPage> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Stack(
children: [
if (data.isTractionLoading && traction.isEmpty) if (data.isTractionLoading && traction.isEmpty)
const Center( const Padding(
child: Padding( padding: EdgeInsets.symmetric(vertical: 32.0),
padding: EdgeInsets.symmetric(vertical: 24.0), child: Center(child: CircularProgressIndicator()),
child: CircularProgressIndicator(),
),
) )
else if (traction.isEmpty) else if (traction.isEmpty)
Card( Card(
@@ -452,7 +453,17 @@ class _TractionPageState extends State<TractionPage> {
else else
Column( Column(
children: [ 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) if (data.tractionHasMore || data.isTractionLoading)
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
@@ -474,6 +485,17 @@ class _TractionPageState extends State<TractionPage> {
), ),
], ],
), ),
if (data.isTractionLoading)
Positioned.fill(
child: IgnorePointer(
child: Container(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
child: const Center(child: CircularProgressIndicator()),
),
),
),
],
),
], ],
), ),
); );
@@ -506,314 +528,31 @@ class _TractionPageState extends State<TractionPage> {
return listView; return listView;
} }
Widget _buildTractionCard(BuildContext context, LocoSummary loco) { void _toggleSelection(LocoSummary loco) {
final keyVal = '${loco.locoClass}-${loco.number}'; 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 Spacer(),
if (widget.selectionMode)
TextButton.icon(
onPressed: () {
if (widget.onSelect != null) { if (widget.onSelect != null) {
widget.onSelect!(loco); widget.onSelect!(loco);
} }
setState(() { setState(() {
if (isSelected) { if (_selectedKeys.contains(keyVal)) {
_selectedKeys.remove(keyVal); _selectedKeys.remove(keyVal);
} else { } else {
_selectedKeys.add(keyVal); _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),
],
),
],
),
),
);
} }
Widget _statPill( bool _isSelected(LocoSummary loco) {
BuildContext context, { final keyVal = '${loco.locoClass}-${loco.number}';
required String label, return _selectedKeys.contains(keyVal);
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) { void _openTimeline(LocoSummary loco) {
final scheme = Theme.of(context).colorScheme; final label = '${loco.locoClass} ${loco.number}'.trim();
final isDark = scheme.brightness == Brightness.dark; context.push(
Color blend( '/traction/${loco.id}/timeline',
Color base, { extra: {'label': label},
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);
}
Future<void> _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( Widget _buildFilterInput(

View File

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

View File

@@ -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<void> 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);
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:mileograph_flutter/components/pages/calculator.dart'; import 'package:mileograph_flutter/components/pages/calculator.dart';
import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
import 'package:mileograph_flutter/components/pages/new_entry.dart'; import 'package:mileograph_flutter/components/pages/new_entry.dart';
import 'package:mileograph_flutter/components/pages/new_traction.dart'; import 'package:mileograph_flutter/components/pages/new_traction.dart';
import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/pages/traction.dart';
@@ -8,9 +9,10 @@ import 'package:mileograph_flutter/components/pages/trips.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:mileograph_flutter/components/pages/legs.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/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'; import 'components/login/login.dart';
import 'components/pages/dashboard.dart'; import 'components/pages/dashboard.dart';
@@ -36,7 +38,7 @@ void main() {
}, },
), ),
ProxyProvider<AuthService, void>( ProxyProvider<AuthService, void>(
update: (_, auth, __) { update: (_, auth, previous) {
api.setTokenProvider(() => auth.token); api.setTokenProvider(() => auth.token);
api.setUnauthorizedHandler(() => auth.handleTokenExpired()); api.setUnauthorizedHandler(() => auth.handleTokenExpired());
}, },
@@ -86,23 +88,55 @@ class MyApp extends StatelessWidget {
return MyHomePage(child: child); return MyHomePage(child: child);
}, },
routes: [ routes: [
GoRoute(path: '/', builder: (_, __) => const Dashboard()), GoRoute(path: '/', builder: (context, state) => const Dashboard()),
GoRoute(path: '/calculator', builder: (_, __) => CalculatorPage()), GoRoute(
path: '/calculator',
builder: (context, state) => CalculatorPage(),
),
GoRoute( GoRoute(
path: '/calculator/details', path: '/calculator/details',
builder: (_, __) => CalculatorPage(), builder: (context, state) => CalculatorPage(),
),
GoRoute(path: '/legs', builder: (context, state) => LegsPage()),
GoRoute(path: '/traction', builder: (context, state) => 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: '/legs', builder: (_, __) => LegsPage()),
GoRoute(path: '/traction', builder: (_, __) => TractionPage()),
GoRoute( GoRoute(
path: '/traction/new', path: '/traction/new',
builder: (_, __) => const NewTractionPage(), builder: (context, state) => const NewTractionPage(),
),
GoRoute(path: '/trips', builder: (context, state) => TripsPage()),
GoRoute(path: '/add', builder: (context, state) => NewEntryPage()),
GoRoute(
path: '/legs/edit/:id',
builder: (_, state) {
final idParam = state.pathParameters['id'];
final legId = idParam == null ? null : int.tryParse(idParam);
return NewEntryPage(editLegId: legId);
},
), ),
GoRoute(path: '/trips', builder: (_, __) => TripsPage()),
GoRoute(path: '/add', builder: (_, __) => NewEntryPage()),
], ],
), ),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
], ],
); );
@@ -180,12 +214,14 @@ class _MyHomePageState extends State<MyHomePage> {
return newIndex; return newIndex;
} }
void _onItemTapped(int index, int currentIndex) { Future<void> _onItemTapped(int index, int currentIndex) async {
if (index < 0 || index >= contentPages.length || index == currentIndex) { if (index < 0 || index >= contentPages.length || index == currentIndex) {
return; return;
} }
context.push(contentPages[index]); await NavigationGuard.attemptNavigation(() async {
_getIndexFromLocation(contentPages[index]); if (!mounted) return;
context.go(contentPages[index]);
});
} }
bool loggedIn = false; bool loggedIn = false;
@@ -199,6 +235,7 @@ class _MyHomePageState extends State<MyHomePage> {
_fetched = true; _fetched = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
Future(() async { Future(() async {
if (!mounted) return;
final data = context.read<DataService>(); final data = context.read<DataService>();
final auth = context.read<AuthService>(); final auth = context.read<AuthService>();
api.setTokenProvider(() => auth.token); api.setTokenProvider(() => auth.token);

View File

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

View File

@@ -97,10 +97,7 @@ class ApiService {
return body; return body;
} }
if (res.statusCode == 401 && if (res.statusCode == 401 && _onUnauthorized != null) {
body is Map<String, dynamic> &&
body['detail'] == 'Not authenticated' &&
_onUnauthorized != null) {
await _onUnauthorized!(); await _onUnauthorized!();
} }

View File

@@ -1,7 +1,7 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/apiService.dart'; import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/tokenStorageService.dart'; import 'package:mileograph_flutter/services/token_storage_service.dart';
class AuthService extends ChangeNotifier { class AuthService extends ChangeNotifier {
final ApiService api; final ApiService api;
@@ -14,10 +14,10 @@ class AuthService extends ChangeNotifier {
AuthenticatedUserData? _user; AuthenticatedUserData? _user;
bool get isLoggedIn => _user != null; bool get isLoggedIn => _user != null;
String? get token => _user?.access_token; String? get token => _user?.accessToken;
String? get userId => _user?.user_id; String? get userId => _user?.userId;
String? get username => _user?.username; String? get username => _user?.username;
String? get fullName => _user?.full_name; String? get fullName => _user?.fullName;
void setLoginData({ void setLoginData({
required String userId, required String userId,
@@ -27,10 +27,10 @@ class AuthService extends ChangeNotifier {
required String email, required String email,
}) { }) {
_user = AuthenticatedUserData( _user = AuthenticatedUserData(
user_id: userId, userId: userId,
username: username, username: username,
full_name: fullName, fullName: fullName,
access_token: accessToken, accessToken: accessToken,
email: email, email: email,
); );
_persistToken(accessToken); _persistToken(accessToken);

View File

@@ -1,9 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:mileograph_flutter/objects/objects.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 { class _LegFetchOptions {
final int limit; final int limit;
@@ -49,6 +50,12 @@ class DataService extends ChangeNotifier {
bool get isTractionLoading => _isTractionLoading; bool get isTractionLoading => _isTractionLoading;
bool _tractionHasMore = false; bool _tractionHasMore = false;
bool get tractionHasMore => _tractionHasMore; bool get tractionHasMore => _tractionHasMore;
final Map<int, List<LocoAttrVersion>> _locoTimelines = {};
final Map<int, bool> _isLocoTimelineLoading = {};
List<LocoAttrVersion> timelineForLoco(int locoId) =>
_locoTimelines[locoId] ?? [];
bool isLocoTimelineLoading(int locoId) =>
_isLocoTimelineLoading[locoId] ?? false;
// Trips // Trips
List<TripSummary> _trips = []; List<TripSummary> _trips = [];
@@ -235,6 +242,24 @@ class DataService extends ChangeNotifier {
} }
} }
Future<List<LocoAttrVersion>> fetchLocoTimeline(int locoId) async {
_isLocoTimelineLoading[locoId] = true;
_notifyAsync();
try {
final json = await api.get('/loco/get-timeline/$locoId');
final timeline = LocoAttrVersion.fromGroupedJson(json);
_locoTimelines[locoId] = timeline;
return timeline;
} catch (e) {
debugPrint('Failed to fetch loco timeline for $locoId: $e');
_locoTimelines[locoId] = [];
return [];
} finally {
_isLocoTimelineLoading[locoId] = false;
_notifyAsync();
}
}
Future<dynamic> createLoco(Map<String, dynamic> payload) async { Future<dynamic> createLoco(Map<String, dynamic> payload) async {
try { try {
final response = await api.put('/loco/new', payload); final response = await api.put('/loco/new', payload);
@@ -417,6 +442,30 @@ class DataService extends ChangeNotifier {
return _locoClasses; return _locoClasses;
} }
Future<void> createLocoEvent({
required int locoId,
required String eventDate,
required Map<String, dynamic> values,
required String details,
String eventType = 'other',
}) async {
try {
await api.put(
'/event/new',
{
'loco_id': locoId,
'loco_event_type': eventType,
'loco_event_date': eventDate,
'loco_event_value': jsonEncode(values),
'loco_event_details': details,
},
);
} catch (e) {
debugPrint('Failed to create loco event: $e');
rethrow;
}
}
void clear() { void clear() {
_homepageStats = null; _homepageStats = null;
_legs = []; _legs = [];
@@ -424,6 +473,8 @@ class DataService extends ChangeNotifier {
_trips = []; _trips = [];
_tripDetails = []; _tripDetails = [];
_eventFields = []; _eventFields = [];
_locoTimelines.clear();
_isLocoTimelineLoading.clear();
_notifyAsync(); _notifyAsync();
} }

View File

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

View File

@@ -66,7 +66,7 @@ packages:
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
collection: collection:
dependency: transitive dependency: "direct main"
description: description:
name: collection name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.1.3+1 version: 0.2.1+1
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1
@@ -36,6 +36,7 @@ dependencies:
provider: ^6.1.5 provider: ^6.1.5
dynamic_color: ^1.6.6 dynamic_color: ^1.6.6
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
collection: ^1.18.0
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.