import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/apiService.dart'; // assumes you've moved HomepageStats + submodels to a separate file 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 _legs = []; List get legs => _legs; List _onThisDay = []; List get onThisDay => _onThisDay; bool _isLegsLoading = false; bool get isLegsLoading => _isLegsLoading; bool _legsHasMore = false; bool get legsHasMore => _legsHasMore; // Traction Data List _traction = []; List get traction => _traction; bool _isTractionLoading = false; bool get isTractionLoading => _isTractionLoading; bool _tractionHasMore = false; bool get tractionHasMore => _tractionHasMore; // Trips List _trips = []; List get trips => _trips; List _tripDetails = []; List get tripDetails => _tripDetails; bool _isTripDetailsLoading = false; bool get isTripDetailsLoading => _isTripDetailsLoading; List _locoClasses = []; List get locoClasses => _locoClasses; List _tripList = []; List get tripList => _tripList; List _eventFields = []; List get eventFields => _eventFields; bool _isEventFieldsLoading = false; bool get isEventFieldsLoading => _isEventFieldsLoading; // Station Data List? _cachedStations; DateTime? _stationsFetchedAt; List stations = [""]; bool _isHomepageLoading = false; bool get isHomepageLoading => _isHomepageLoading; bool _isOnThisDayLoading = false; bool get isOnThisDayLoading => _isOnThisDayLoading; static const List _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 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 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 refreshLegs() { return fetchLegs( limit: _lastLegsFetch.limit, sortBy: _lastLegsFetch.sortBy, sortDirection: _lastLegsFetch.sortDirection, dateRangeStart: _lastLegsFetch.dateRangeStart, dateRangeEnd: _lastLegsFetch.dateRangeEnd, ); } Future fetchHadTraction({int offset = 0, int limit = 100}) async { await fetchTraction( hadOnly: true, offset: offset, limit: limit, append: offset > 0, ); } Future fetchTraction({ bool hadOnly = false, int offset = 0, int limit = 50, String? locoClass, String? locoNumber, bool mileageFirst = true, bool append = false, Map? 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 = {}; 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 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 fetchTripDetails() async { _isTripDetailsLoading = true; try { final json = await api.get('/trips/legs-and-stats'); if (json is List) { final trip_map = json.map((e) => TripDetail.fromJson(e)).toList(); _tripDetails = [...trip_map]..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> fetchTripLocoStats(int tripId) async { try { final json = await api.get('/trips/stats?trip_id=$tripId'); if (json is List) { return json .whereType>() .map((e) => TripLocoStat.fromJson(e)) .toList(); } if (json is Map && json['locos'] is List) { return (json['locos'] as List) .whereType>() .map((e) => TripLocoStat.fromJson(e)) .toList(); } return []; } catch (e) { debugPrint('Failed to fetch trip loco stats: $e'); return []; } } Future fetchEventFields({bool force = false}) async { if (_eventFields.isNotEmpty && !force) return; _isEventFieldsLoading = true; _notifyAsync(); try { final json = await api.get('/event/fields'); List 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 _parseEventFields(dynamic json) { if (json is List) { return json .whereType>() .map(EventField.fromJson) .toList(); } if (json is Map) { if (json['fields'] is List) { return (json['fields'] as List) .whereType>() .map(EventField.fromJson) .toList(); } // If map of name -> definition return json.entries .where((entry) => entry.value is Map) .map((entry) { final map = Map.from(entry.value); map['name'] = entry.key; return EventField.fromJson(map); }) .toList(); } return []; } Future fetchTrips() async { try { final json = await api.get('/trips/mileage'); Iterable? 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 trip_map = raw .whereType>() .map((e) => TripSummary.fromJson(e)) .toList(); _tripList = [...trip_map]..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> 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; } void clear() { _homepageStats = null; _legs = []; _onThisDay = []; _trips = []; _tripDetails = []; _eventFields = []; _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> 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; } }