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

This commit is contained in:
2025-12-16 16:49:39 +00:00
parent 4a6aee8a15
commit 80be797322
23 changed files with 1517 additions and 1414 deletions

View File

@@ -0,0 +1,514 @@
import 'dart:async';
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/api_service.dart'; // assumes you've moved HomepageStats + submodels to a separate file
class _LegFetchOptions {
final int limit;
final String sortBy;
final int sortDirection;
final String? dateRangeStart;
final String? dateRangeEnd;
const _LegFetchOptions({
this.limit = 100,
this.sortBy = 'date',
this.sortDirection = 0,
this.dateRangeStart,
this.dateRangeEnd,
});
}
class DataService extends ChangeNotifier {
final ApiService api;
DataService({required this.api});
_LegFetchOptions _lastLegsFetch = const _LegFetchOptions();
// Homepage Data
HomepageStats? _homepageStats;
HomepageStats? get homepageStats => _homepageStats;
// Legs Data
List<Leg> _legs = [];
List<Leg> get legs => _legs;
List<Leg> _onThisDay = [];
List<Leg> get onThisDay => _onThisDay;
bool _isLegsLoading = false;
bool get isLegsLoading => _isLegsLoading;
bool _legsHasMore = false;
bool get legsHasMore => _legsHasMore;
// Traction Data
List<LocoSummary> _traction = [];
List<LocoSummary> get traction => _traction;
bool _isTractionLoading = false;
bool get isTractionLoading => _isTractionLoading;
bool _tractionHasMore = false;
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
List<TripSummary> _trips = [];
List<TripSummary> get trips => _trips;
List<TripDetail> _tripDetails = [];
List<TripDetail> get tripDetails => _tripDetails;
bool _isTripDetailsLoading = false;
bool get isTripDetailsLoading => _isTripDetailsLoading;
List<String> _locoClasses = [];
List<String> get locoClasses => _locoClasses;
List<TripSummary> _tripList = [];
List<TripSummary> get tripList => _tripList;
List<EventField> _eventFields = [];
List<EventField> get eventFields => _eventFields;
bool _isEventFieldsLoading = false;
bool get isEventFieldsLoading => _isEventFieldsLoading;
// Station Data
List<Station>? _cachedStations;
DateTime? _stationsFetchedAt;
List<String> stations = [""];
bool _isHomepageLoading = false;
bool get isHomepageLoading => _isHomepageLoading;
bool _isOnThisDayLoading = false;
bool get isOnThisDayLoading => _isOnThisDayLoading;
static const List<EventField> _fallbackEventFields = [
EventField(name: 'operator', display: 'Operator'),
EventField(name: 'status', display: 'Status'),
EventField(name: 'evn', display: 'EVN'),
EventField(name: 'owner', display: 'Owner'),
EventField(name: 'location', display: 'Location'),
EventField(name: 'livery', display: 'Livery'),
EventField(name: 'domain', display: 'Domain'),
EventField(name: 'type', display: 'Type'),
];
void _notifyAsync() {
// Always defer to the next frame to avoid setState during build.
SchedulerBinding.instance.addPostFrameCallback((_) {
notifyListeners();
});
}
Future<void> fetchHomepageStats() async {
_isHomepageLoading = true;
try {
final json = await api.get('/stats/homepage');
_homepageStats = HomepageStats.fromJson(json);
_trips = _homepageStats?.trips ?? [];
} catch (e) {
debugPrint('Failed to fetch homepage stats: $e');
_homepageStats = null;
_trips = [];
} finally {
_isHomepageLoading = false;
_notifyAsync();
}
}
Future<void> fetchLegs({
int offset = 0,
int limit = 100,
String sortBy = 'date',
int sortDirection = 0,
String? dateRangeStart,
String? dateRangeEnd,
bool append = false,
}) async {
_isLegsLoading = true;
if (!append) {
_lastLegsFetch = _LegFetchOptions(
limit: limit,
sortBy: sortBy,
sortDirection: sortDirection,
dateRangeStart: dateRangeStart,
dateRangeEnd: dateRangeEnd,
);
}
final buffer = StringBuffer(
'?sort_direction=$sortDirection&sort_by=$sortBy&offset=$offset&limit=$limit',
);
if (dateRangeStart != null && dateRangeStart.isNotEmpty) {
buffer.write('&date_range_start=$dateRangeStart');
}
if (dateRangeEnd != null && dateRangeEnd.isNotEmpty) {
buffer.write('&date_range_end=$dateRangeEnd');
}
try {
final json = await api.get('/user/legs${buffer.toString()}');
if (json is List) {
final newLegs = json.map((e) => Leg.fromJson(e)).toList();
_legs = append ? [..._legs, ...newLegs] : newLegs;
_legsHasMore = newLegs.length >= limit;
} else {
throw Exception('Unexpected legs response: $json');
}
} catch (e) {
debugPrint('Failed to fetch legs: $e');
if (!append) _legs = [];
_legsHasMore = false;
} finally {
_isLegsLoading = false;
_notifyAsync();
}
}
Future<void> refreshLegs() {
return fetchLegs(
limit: _lastLegsFetch.limit,
sortBy: _lastLegsFetch.sortBy,
sortDirection: _lastLegsFetch.sortDirection,
dateRangeStart: _lastLegsFetch.dateRangeStart,
dateRangeEnd: _lastLegsFetch.dateRangeEnd,
);
}
Future<void> fetchHadTraction({int offset = 0, int limit = 100}) async {
await fetchTraction(
hadOnly: true,
offset: offset,
limit: limit,
append: offset > 0,
);
}
Future<void> fetchTraction({
bool hadOnly = false,
int offset = 0,
int limit = 50,
String? locoClass,
String? locoNumber,
bool mileageFirst = true,
bool append = false,
Map<String, dynamic>? filters,
}) async {
_isTractionLoading = true;
try {
final params = StringBuffer('?limit=$limit&offset=$offset');
if (hadOnly) params.write('&had_only=true');
if (!mileageFirst) params.write('&mileage_first=false');
final payload = <String, dynamic>{};
if (locoClass != null && locoClass.isNotEmpty) {
payload['class'] = locoClass;
}
if (locoNumber != null && locoNumber.isNotEmpty) {
payload['number'] = locoNumber;
}
if (filters != null) {
filters.forEach((key, value) {
if (value == null) return;
if (value is String && value.trim().isEmpty) return;
payload[key] = value;
});
}
final json = await api.post(
'/locos/search/v2${params.toString()}',
payload.isEmpty ? null : payload,
);
if (json is List) {
final newItems = json.map((e) => LocoSummary.fromJson(e)).toList();
_traction = append ? [..._traction, ...newItems] : newItems;
_tractionHasMore = newItems.length >= limit - 1;
} else {
throw Exception('Unexpected traction response: $json');
}
} catch (e) {
debugPrint('Failed to fetch traction: $e');
if (!append) {
_traction = [];
}
_tractionHasMore = false;
} finally {
_isTractionLoading = false;
_notifyAsync();
}
}
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 {
try {
final response = await api.put('/loco/new', payload);
final locoClass = payload['class']?.toString();
if (locoClass != null &&
locoClass.isNotEmpty &&
!_locoClasses.contains(locoClass)) {
_locoClasses = [..._locoClasses, locoClass];
}
_notifyAsync();
return response;
} catch (e) {
debugPrint('Failed to create loco: $e');
rethrow;
}
}
Future<void> fetchOnThisDay({DateTime? date}) async {
_isOnThisDayLoading = true;
final target = date ?? DateTime.now();
final formatted =
"${target.year.toString().padLeft(4, '0')}-${target.month.toString().padLeft(2, '0')}-${target.day.toString().padLeft(2, '0')}";
try {
final json = await api.get('/legs/on-this-day?date=$formatted');
if (json is List) {
_onThisDay = json.map((e) => Leg.fromJson(e)).toList();
} else {
_onThisDay = [];
}
} catch (e) {
debugPrint('Failed to fetch on-this-day legs: $e');
_onThisDay = [];
} finally {
_isOnThisDayLoading = false;
_notifyAsync();
}
}
Future<void> fetchTripDetails() async {
_isTripDetailsLoading = true;
try {
final json = await api.get('/trips/legs-and-stats');
if (json is List) {
final tripMap = json.map((e) => TripDetail.fromJson(e)).toList();
_tripDetails = [...tripMap]..sort((a, b) => b.id.compareTo(a.id));
} else {
_tripDetails = [];
}
} catch (e) {
debugPrint('Failed to fetch trip_map: $e');
_tripDetails = [];
} finally {
_isTripDetailsLoading = false;
_notifyAsync();
}
}
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
try {
final json = await api.get('/trips/stats/$tripId');
return _parseTripLocoStats(json);
} catch (e) {
debugPrint('Failed to fetch trip loco stats: $e');
return [];
}
}
List<TripLocoStat> _parseTripLocoStats(dynamic json) {
List<dynamic>? list;
if (json is List) {
list = json.expand((e) => e is List ? e : [e]).toList();
} else if (json is Map) {
for (final key in ['locos', 'stats', 'data', 'trip_locos']) {
final candidate = json[key];
if (candidate is List) {
list = candidate.expand((e) => e is List ? e : [e]).toList();
break;
}
}
}
if (list == null) return [];
return list
.whereType<Map<String, dynamic>>()
.map((e) => TripLocoStat.fromJson(e))
.toList();
}
Future<void> fetchEventFields({bool force = false}) async {
if (_eventFields.isNotEmpty && !force) return;
_isEventFieldsLoading = true;
_notifyAsync();
try {
final json = await api.get('/event/fields');
List<EventField> fields = _parseEventFields(json);
if (fields.isEmpty) {
fields = _fallbackEventFields;
}
_eventFields = fields;
} catch (e) {
debugPrint('Failed to fetch event fields: $e');
_eventFields = _fallbackEventFields;
} finally {
_isEventFieldsLoading = false;
_notifyAsync();
}
}
List<EventField> _parseEventFields(dynamic json) {
if (json is List) {
return json
.whereType<Map<String, dynamic>>()
.map(EventField.fromJson)
.toList();
}
if (json is Map) {
if (json['fields'] is List) {
return (json['fields'] as List)
.whereType<Map<String, dynamic>>()
.map(EventField.fromJson)
.toList();
}
// If map of name -> definition
return json.entries
.where((entry) => entry.value is Map<String, dynamic>)
.map((entry) {
final map = Map<String, dynamic>.from(entry.value);
map['name'] = entry.key;
return EventField.fromJson(map);
})
.toList();
}
return [];
}
Future<void> fetchTrips() async {
try {
final json = await api.get('/trips/mileage');
Iterable<dynamic>? raw;
if (json is List) {
raw = json;
} else if (json is Map) {
for (final key in ['trips', 'trip_data', 'data']) {
final value = json[key];
if (value is List) {
raw = value;
break;
}
}
}
if (raw != null) {
final tripMap = raw
.whereType<Map<String, dynamic>>()
.map((e) => TripSummary.fromJson(e))
.toList();
_tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId));
} else {
debugPrint('Unexpected trip list response: $json');
_tripList = [];
}
} catch (e) {
debugPrint('Failed to fetch trip list: $e');
_tripList = [];
} finally {
_notifyAsync();
}
}
Future<List<String>> fetchClassList() async {
if (_locoClasses.isNotEmpty) return _locoClasses;
try {
final json = await api.get('/loco/classlist');
if (json is List) {
_locoClasses = json.map((e) => e.toString()).toList();
_notifyAsync();
}
} catch (e) {
debugPrint('Failed to fetch class list: $e');
}
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() {
_homepageStats = null;
_legs = [];
_onThisDay = [];
_trips = [];
_tripDetails = [];
_eventFields = [];
_locoTimelines.clear();
_isLocoTimelineLoading.clear();
_notifyAsync();
}
double getMileageForCurrentYear() {
final currentYear = DateTime.now().year;
return getMileageForYear(currentYear) ?? 0;
}
double? getMileageForYear(int year) {
return _homepageStats?.yearlyMileage
.firstWhere(
(entry) => entry.year == year,
orElse: () => YearlyMileage(year: null, mileage: 0),
)
.mileage ??
0;
}
Future<List<Station>> fetchStations() async {
final now = DateTime.now();
// If cache exists and is less than 30 minutes old, return it
if (_cachedStations != null &&
_stationsFetchedAt != null &&
now.difference(_stationsFetchedAt!) < Duration(minutes: 30)) {
return _cachedStations!;
}
final response = await api.get('/location');
final parsed = (response as List).map((e) => Station.fromJson(e)).toList();
_cachedStations = parsed;
_stationsFetchedAt = now;
return parsed;
}
}