4 Commits

Author SHA1 Message Date
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
8 changed files with 1553 additions and 55 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:
@@ -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"

File diff suppressed because it is too large Load Diff

View File

@@ -423,57 +423,68 @@ class _TractionPageState extends State<TractionPage> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (data.isTractionLoading && traction.isEmpty) Stack(
const Center( children: [
child: Padding( if (data.isTractionLoading && traction.isEmpty)
padding: EdgeInsets.symmetric(vertical: 24.0), const Padding(
child: CircularProgressIndicator(), padding: EdgeInsets.symmetric(vertical: 32.0),
), child: Center(child: CircularProgressIndicator()),
) )
else if (traction.isEmpty) else if (traction.isEmpty)
Card( Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'No traction found', 'No traction found',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text('Try relaxing the filters or sync again.'), const Text('Try relaxing the filters or sync again.'),
], ],
),
),
)
else
Column(
children: [
...traction.map((loco) => _buildTractionCard(context, loco)),
if (data.tractionHasMore || data.isTractionLoading)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton.icon(
onPressed: data.isTractionLoading
? null
: () => _refreshTraction(append: true),
icon: data.isTractionLoading
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(
data.isTractionLoading ? 'Loading...' : 'Load more',
),
), ),
), ),
], )
), else
Column(
children: [
...traction.map((loco) => _buildTractionCard(context, loco)),
if (data.tractionHasMore || data.isTractionLoading)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton.icon(
onPressed: data.isTractionLoading
? null
: () => _refreshTraction(append: true),
icon: data.isTractionLoading
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(
data.isTractionLoading ? 'Loading...' : 'Load more',
),
),
),
],
),
if (data.isTractionLoading)
Positioned.fill(
child: IgnorePointer(
child: Container(
color: Theme.of(context).colorScheme.surface.withOpacity(0.6),
child: const Center(child: CircularProgressIndicator()),
),
),
),
],
),
], ],
), ),
); );
@@ -574,6 +585,12 @@ class _TractionPageState extends State<TractionPage> {
icon: const Icon(Icons.info_outline), icon: const Icon(Icons.info_outline),
label: const Text('Details'), label: const Text('Details'),
), ),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () => _openTimeline(loco),
icon: const Icon(Icons.timeline),
label: const Text('Timeline'),
),
const Spacer(), const Spacer(),
if (widget.selectionMode) if (widget.selectionMode)
TextButton.icon( TextButton.icon(
@@ -692,6 +709,14 @@ class _TractionPageState extends State<TractionPage> {
return (background, foreground); return (background, foreground);
} }
void _openTimeline(LocoSummary loco) {
final label = '${loco.locoClass} ${loco.number}'.trim();
context.push(
'/traction/${loco.id}/timeline',
extra: {'label': label},
);
}
Future<void> _showLocoInfo(LocoSummary loco) async { Future<void> _showLocoInfo(LocoSummary loco) async {
await showModalBottomSheet( await showModalBottomSheet(
context: context, context: context,

View File

@@ -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';
@@ -95,6 +96,27 @@ class MyApp extends StatelessWidget {
), ),
GoRoute(path: '/legs', builder: (_, __) => LegsPage()), GoRoute(path: '/legs', builder: (_, __) => LegsPage()),
GoRoute(path: '/traction', builder: (_, __) => TractionPage()), GoRoute(path: '/traction', builder: (_, __) => TractionPage()),
GoRoute(
path: '/traction/:id/timeline',
builder: (_, state) {
final idParam = state.pathParameters['id'];
final locoId = int.tryParse(idParam ?? '') ?? 0;
final extra = state.extra;
String label = state.uri.queryParameters['label'] ?? '';
if (extra is Map && extra['label'] is String) {
label = extra['label'] as String;
} else if (extra is String && extra.isNotEmpty) {
label = extra;
}
if (label.trim().isEmpty) {
label = 'Loco $locoId';
}
return LocoTimelinePage(
locoId: locoId,
locoLabel: label,
);
},
),
GoRoute( GoRoute(
path: '/traction/new', path: '/traction/new',
builder: (_, __) => const NewTractionPage(), builder: (_, __) => const NewTractionPage(),

View File

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

View File

@@ -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,4 +1,5 @@
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';
@@ -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

@@ -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.5+1 version: 0.2.0+1
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1