diff --git a/lib/components/calculator/calculator.dart b/lib/components/calculator/calculator.dart index 2fb0a0b..54e8caa 100644 --- a/lib/components/calculator/calculator.dart +++ b/lib/components/calculator/calculator.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/api_service.dart'; +import 'package:mileograph_flutter/components/widgets/multi_select_filter.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import './route_summary_widget.dart'; @@ -358,7 +359,7 @@ class _RouteCalculatorState extends State { runSpacing: 12, crossAxisAlignment: WrapCrossAlignment.center, children: [ - _MultiSelectFilter( + MultiSelectFilter( label: 'Countries', options: _countries, selected: _selectedCountries, @@ -367,7 +368,7 @@ class _RouteCalculatorState extends State { _loadStations(); }, ), - _MultiSelectFilter( + MultiSelectFilter( label: 'Networks', options: _networks, selected: _selectedNetworks, @@ -375,6 +376,16 @@ class _RouteCalculatorState extends State { setState(() => _selectedNetworks = vals); _loadStations(); }, + onRefresh: () async { + final data = context.read(); + await data.fetchStationFilters(); + if (!mounted) return; + setState(() { + _networks = data.stationNetworks; + _countries = data.stationCountryNetworks.keys.toList(); + }); + await _loadStations(); + }, ), if (_loadingStations) const Padding( @@ -573,159 +584,3 @@ Widget debugPanel(List stations) { ), ); } - -class _MultiSelectFilter extends StatefulWidget { - const _MultiSelectFilter({ - required this.label, - required this.options, - required this.selected, - required this.onChanged, - }); - - final String label; - final List options; - final List selected; - final ValueChanged> onChanged; - - @override - State<_MultiSelectFilter> createState() => _MultiSelectFilterState(); -} - -class _MultiSelectFilterState extends State<_MultiSelectFilter> { - late List _tempSelected; - String _query = ''; - - @override - void initState() { - super.initState(); - _tempSelected = List.from(widget.selected); - } - - @override - void didUpdateWidget(covariant _MultiSelectFilter oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.selected != widget.selected) { - _tempSelected = List.from(widget.selected); - } - } - - void _openPicker() async { - _tempSelected = List.from(widget.selected); - _query = ''; - await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (ctx) { - return StatefulBuilder( - builder: (ctx, setModalState) { - final filtered = widget.options - .where((opt) => - _query.isEmpty || opt.toLowerCase().contains(_query.toLowerCase())) - .toList(); - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Select ${widget.label.toLowerCase()}', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const Spacer(), - TextButton( - onPressed: () { - setModalState(() { - _tempSelected.clear(); - }); - Navigator.of(ctx).pop(); - widget.onChanged(const []); - }, - child: const Text('Clear'), - ), - ], - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: 'Search', - border: OutlineInputBorder(), - ), - onChanged: (val) { - setModalState(() { - _query = val; - }); - }, - ), - const SizedBox(height: 12), - SizedBox( - height: 320, - child: ListView.builder( - itemCount: filtered.length, - itemBuilder: (_, index) { - final option = filtered[index]; - final selected = _tempSelected.contains(option); - return CheckboxListTile( - value: selected, - title: Text(option), - onChanged: (val) { - setModalState(() { - if (val == true) { - if (!_tempSelected.contains(option)) { - _tempSelected.add(option); - } - } else { - _tempSelected.removeWhere((e) => e == option); - } - }); - widget.onChanged(List.from(_tempSelected.toSet())); - }, - ); - }, - ), - ), - const SizedBox(height: 12), - Align( - alignment: Alignment.centerRight, - child: FilledButton.icon( - onPressed: () { - widget.onChanged(List.from(_tempSelected.toSet())); - Navigator.of(ctx).pop(); - }, - icon: const Icon(Icons.check), - label: const Text('Apply'), - ), - ), - ], - ), - ), - ); - }, - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - final hasSelection = widget.selected.isNotEmpty; - final display = - hasSelection ? widget.selected.join(', ') : 'Any ${widget.label.toLowerCase()}'; - return OutlinedButton.icon( - onPressed: _openPicker, - icon: const Icon(Icons.filter_alt), - label: SizedBox( - width: 180, - child: Text( - '${widget.label}: $display', - overflow: TextOverflow.ellipsis, - ), - ), - ); - } -} diff --git a/lib/components/login/login.dart b/lib/components/login/login.dart index 4cf759f..8a9de53 100644 --- a/lib/components/login/login.dart +++ b/lib/components/login/login.dart @@ -95,21 +95,7 @@ class _LoginScreenState extends State { ); }, ), - const SizedBox(height: 50), - const LoginPanel(), - const SizedBox(height: 16), - IconButton( - icon: const Icon(Icons.settings, color: Colors.grey), - tooltip: 'Settings', - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => const SettingsPage(), - ), - ); - }, - ), + const SizedBox(height: 40), if (_checkingSession) ...[ const SizedBox(height: 12), Row( @@ -127,6 +113,22 @@ class _LoginScreenState extends State { ), ], ), + ] else ...[ + const SizedBox(height: 10), + const LoginPanel(), + const SizedBox(height: 16), + IconButton( + icon: const Icon(Icons.settings, color: Colors.grey), + tooltip: 'Settings', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => const SettingsPage(), + ), + ); + }, + ), ], ], ), diff --git a/lib/components/pages/legs.dart b/lib/components/pages/legs.dart index c3debbe..3f33318 100644 --- a/lib/components/pages/legs.dart +++ b/lib/components/pages/legs.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/components/legs/leg_card.dart'; +import 'package:mileograph_flutter/components/widgets/multi_select_filter.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; @@ -19,6 +20,9 @@ class _LegsPageState extends State { bool _initialised = false; bool _unallocatedOnly = false; bool _showMoreFilters = false; + bool _loadingNetworks = false; + List _availableNetworks = []; + List _selectedNetworks = []; @override void didChangeDependencies() { @@ -26,9 +30,21 @@ class _LegsPageState extends State { if (!_initialised) { _initialised = true; _refreshLegs(); + _loadNetworks(); } } + Future _loadNetworks() async { + setState(() => _loadingNetworks = true); + final data = context.read(); + await data.fetchStationNetworks(); + if (!mounted) return; + setState(() { + _availableNetworks = data.stationNetworks; + _loadingNetworks = false; + }); + } + Future _refreshLegs() async { final data = context.read(); await data.fetchLegs( @@ -36,6 +52,7 @@ class _LegsPageState extends State { dateRangeStart: _formatDate(_startDate), dateRangeEnd: _formatDate(_endDate), unallocatedOnly: _unallocatedOnly, + networkFilter: _selectedNetworks, ); } @@ -48,6 +65,7 @@ class _LegsPageState extends State { offset: data.legs.length, append: true, unallocatedOnly: _unallocatedOnly, + networkFilter: _selectedNetworks, ); } @@ -90,6 +108,7 @@ class _LegsPageState extends State { _sortDirection = 0; _unallocatedOnly = false; _showMoreFilters = false; + _selectedNetworks = []; }); _refreshLegs(); } @@ -209,6 +228,16 @@ class _LegsPageState extends State { spacing: 12, runSpacing: 12, children: [ + MultiSelectFilter( + label: 'Networks', + options: _availableNetworks, + selected: _selectedNetworks, + onChanged: (vals) async { + setState(() => _selectedNetworks = vals); + await _refreshLegs(); + }, + onRefresh: _loadingNetworks ? null : _loadNetworks, + ), FilterChip( avatar: const Icon(Icons.flash_off), label: const Text('Unallocated only'), @@ -218,6 +247,15 @@ class _LegsPageState extends State { await _refreshLegs(); }, ), + if (_loadingNetworks) + const Padding( + padding: EdgeInsets.only(left: 8.0), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), ], ), ), diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index b266b44..a00b834 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -1,6 +1,7 @@ part of 'traction.dart'; enum _TractionMoreAction { + exportResults, classStats, classLeaderboard, adminPending, @@ -68,14 +69,14 @@ class _TractionPageState extends State { _ClassLeaderboardScope _classLeaderboardScope = _ClassLeaderboardScope.global; final Map _dynamicControllers = {}; - final Map _enumSelections = {}; + final Map _enumSelections = {}; bool _restoredFromPrefs = false; static const int _pageSize = 100; int _lastTractionOffset = 0; String? _lastQuerySignature; String? _transferFromLabel; bool _isSearching = false; - bool _classExporting = false; + bool _exporting = false; @override void initState() { @@ -169,12 +170,7 @@ class _TractionPageState extends State { ].join(';'); } - Future _refreshTraction({ - bool append = false, - bool preservePosition = true, - }) async { - _setState(() => _isSearching = true); - final data = context.read(); + Map _buildTractionFilters() { final filters = {}; final name = _nameController.text.trim(); if (name.isNotEmpty) filters['name'] = name; @@ -187,6 +183,16 @@ class _TractionPageState extends State { filters[key] = value; } }); + return filters; + } + + Future _refreshTraction({ + bool append = false, + bool preservePosition = true, + }) async { + _setState(() => _isSearching = true); + final data = context.read(); + final filters = _buildTractionFilters(); final hadOnly = !_hasFilters; final signature = _tractionQuerySignature(filters, hadOnly); final queryChanged = @@ -673,23 +679,6 @@ class _TractionPageState extends State { ); final hasClassActions = _hasClassQuery; - final classLabel = _currentClassLabel; - - final exportClassButton = !hasClassActions - ? null - : FilledButton.tonalIcon( - onPressed: _classExporting ? null : _exportSelectedClass, - icon: _classExporting - ? const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.download), - label: Text( - _classExporting ? 'Exporting...' : 'Export $classLabel', - ), - ); final newTractionButton = FilledButton.icon( onPressed: () async { @@ -710,7 +699,7 @@ class _TractionPageState extends State { ); final hasAdminActions = isElevated; - final hasMoreMenu = hasClassActions || hasAdminActions; + final hasMoreMenu = true; final moreButton = !hasMoreMenu ? null @@ -718,6 +707,9 @@ class _TractionPageState extends State { tooltip: 'More options', onSelected: (action) async { switch (action) { + case _TractionMoreAction.exportResults: + await _exportTractionResults(); + break; case _TractionMoreAction.classStats: _toggleClassStatsPanel(); break; @@ -759,6 +751,25 @@ class _TractionPageState extends State { }, itemBuilder: (context) { final items = >[]; + items.add( + PopupMenuItem( + value: _TractionMoreAction.exportResults, + child: Row( + children: [ + if (_exporting) + const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + const Icon(Icons.download, size: 18), + const SizedBox(width: 8), + Text(_exporting ? 'Exporting...' : 'Export'), + ], + ), + ), + ); if (hasClassActions) { items.add( PopupMenuItem( @@ -847,14 +858,12 @@ class _TractionPageState extends State { final desktopActions = [ refreshButton, - if (exportClassButton != null) exportClassButton, newTractionButton, if (moreButton != null) moreButton, ]; final mobileActions = [ if (moreButton != null) moreButton, - if (exportClassButton != null) exportClassButton, newTractionButton, refreshButton, ]; @@ -882,23 +891,36 @@ class _TractionPageState extends State { ); } - Future _exportSelectedClass() async { - if (_classExporting) return; - final classLabel = _currentClassLabel; - if (classLabel.isEmpty) return; - setState(() => _classExporting = true); + Future _exportTractionResults() async { + if (_exporting) return; + setState(() => _exporting = true); final messenger = ScaffoldMessenger.of(context); try { final data = context.read(); - final encodedClass = Uri.encodeComponent(classLabel); - final response = await data.api.getBytes( - '/loco/class/export/$encodedClass', + final filters = _buildTractionFilters(); + final hadOnly = !_hasFilters; + final limit = data.traction.length; + final params = StringBuffer('?limit=$limit&offset=0'); + if (hadOnly) params.write('&had_only=true'); + if (!_mileageFirst) params.write('&mileage_first=false'); + + final payload = {}; + final classLabel = (_selectedClass ?? _classController.text).trim(); + if (classLabel.isNotEmpty) payload['class'] = classLabel; + final numberLabel = _numberController.text.trim(); + if (numberLabel.isNotEmpty) payload['number'] = numberLabel; + filters.forEach((key, value) { + if (value == null) return; + if (value is String && value.trim().isEmpty) return; + payload[key] = value; + }); + + final response = await data.api.postBytes( + '/locos/search/v2/export${params.toString()}', + payload.isEmpty ? null : payload, headers: const {'accept': '*/*'}, ); - final safeClassName = - classLabel.replaceAll(RegExp(r'[^a-zA-Z0-9-_]+'), '_'); - final fallbackName = 'traction-${safeClassName.isEmpty ? 'class' : safeClassName}.xlsx'; - final filename = response.filename ?? fallbackName; + final filename = response.filename ?? 'traction-export.xlsx'; final saveResult = await saveBytes( Uint8List.fromList(response.bytes), filename, @@ -932,7 +954,7 @@ class _TractionPageState extends State { SnackBar(content: Text('Export failed: $e')), ); } finally { - if (mounted) setState(() => _classExporting = false); + if (mounted) setState(() => _exporting = false); } } @@ -1847,13 +1869,44 @@ class _TractionPageState extends State { bool isMobile, ) { final width = isMobile ? double.infinity : 220.0; + final type = field.type?.toLowerCase() ?? ''; + final isBooleanField = + type == 'bool' || type == 'boolean' || type.contains('bool'); + if (isBooleanField) { + final currentValue = _enumSelections[field.name]; + final safeValue = currentValue is bool ? currentValue : null; + return SizedBox( + width: width, + child: DropdownButtonFormField( + value: safeValue, + decoration: InputDecoration( + labelText: field.display, + border: const OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: null, child: Text('Any')), + DropdownMenuItem(value: true, child: Text('Yes')), + DropdownMenuItem(value: false, child: Text('No')), + ], + onChanged: (val) { + setState(() { + _enumSelections[field.name] = val; + }); + _refreshTraction(); + }, + ), + ); + } if (field.enumValues != null && field.enumValues!.isNotEmpty) { final options = field.enumValues! .map((e) => e.toString()) .toSet() .toList(); final currentValue = _enumSelections[field.name]; - final safeValue = options.contains(currentValue) ? currentValue : null; + final safeValue = + currentValue is String && options.contains(currentValue) + ? currentValue + : null; return SizedBox( width: width, child: DropdownButtonFormField( @@ -1883,7 +1936,6 @@ class _TractionPageState extends State { _dynamicControllers[field.name] = controller; TextInputType? inputType; if (field.type != null) { - final type = field.type!.toLowerCase(); if (type.contains('int') || type.contains('num') || type.contains('double')) { diff --git a/lib/components/widgets/multi_select_filter.dart b/lib/components/widgets/multi_select_filter.dart new file mode 100644 index 0000000..ae4d146 --- /dev/null +++ b/lib/components/widgets/multi_select_filter.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; + +class MultiSelectFilter extends StatefulWidget { + const MultiSelectFilter({ + super.key, + required this.label, + required this.options, + required this.selected, + required this.onChanged, + this.onRefresh, + }); + + final String label; + final List options; + final List selected; + final ValueChanged> onChanged; + final VoidCallback? onRefresh; + + @override + State createState() => _MultiSelectFilterState(); +} + +class _MultiSelectFilterState extends State { + late List _tempSelected; + String _query = ''; + + @override + void initState() { + super.initState(); + _tempSelected = List.from(widget.selected); + } + + @override + void didUpdateWidget(covariant MultiSelectFilter oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selected != widget.selected) { + _tempSelected = List.from(widget.selected); + } + } + + void _openPicker() async { + _tempSelected = List.from(widget.selected); + _query = ''; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setModalState) { + final filtered = widget.options + .where((opt) => + _query.isEmpty || opt.toLowerCase().contains(_query.toLowerCase())) + .toList(); + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Select ${widget.label.toLowerCase()}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + if (widget.onRefresh != null) + IconButton( + tooltip: 'Refresh', + onPressed: widget.onRefresh, + icon: const Icon(Icons.refresh), + ), + TextButton( + onPressed: () { + setModalState(() { + _tempSelected.clear(); + }); + Navigator.of(ctx).pop(); + widget.onChanged(const []); + }, + child: const Text('Clear'), + ), + ], + ), + const SizedBox(height: 8), + TextField( + decoration: const InputDecoration( + labelText: 'Search', + border: OutlineInputBorder(), + ), + onChanged: (val) { + setModalState(() { + _query = val; + }); + }, + ), + const SizedBox(height: 12), + SizedBox( + height: 320, + child: ListView.builder( + itemCount: filtered.length, + itemBuilder: (_, index) { + final option = filtered[index]; + final selected = _tempSelected.contains(option); + return CheckboxListTile( + value: selected, + title: Text(option), + onChanged: (val) { + setModalState(() { + if (val == true) { + if (!_tempSelected.contains(option)) { + _tempSelected.add(option); + } + } else { + _tempSelected.removeWhere((e) => e == option); + } + }); + widget.onChanged(List.from(_tempSelected.toSet())); + }, + ); + }, + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: () { + widget.onChanged(List.from(_tempSelected.toSet())); + Navigator.of(ctx).pop(); + }, + icon: const Icon(Icons.check), + label: const Text('Apply'), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final hasSelection = widget.selected.isNotEmpty; + final display = + hasSelection ? widget.selected.join(', ') : 'Any ${widget.label.toLowerCase()}'; + return OutlinedButton.icon( + onPressed: _openPicker, + icon: const Icon(Icons.filter_alt), + label: SizedBox( + width: 180, + child: Text( + '${widget.label}: $display', + overflow: TextOverflow.ellipsis, + ), + ), + ); + } +} diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 88df4b8..74a5be2 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -119,6 +119,45 @@ class ApiService { return _processResponse(response); } + Future postBytes( + String endpoint, + dynamic data, { + Map? headers, + bool includeAuth = true, + bool allowRetry = true, + }) async { + final hasBody = data != null; + final response = await _sendWithRetry( + () => _client.post( + Uri.parse('$baseUrl$endpoint'), + headers: _buildHeaders( + hasBody ? _jsonHeaders(headers) : headers, + includeAuth: includeAuth, + ), + body: hasBody ? jsonEncode(data) : null, + ), + allowRetry: allowRetry, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + final contentDisposition = response.headers['content-disposition']; + return ApiBinaryResponse( + bytes: response.bodyBytes, + statusCode: response.statusCode, + contentType: response.headers['content-type'], + filename: _extractFilename(contentDisposition), + ); + } + + final body = _decodeBody(response); + final message = _extractErrorMessage(body); + throw ApiException( + statusCode: response.statusCode, + message: message, + body: body, + ); + } + Future postMultipartFile( String endpoint, { required List bytes, diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index e4f151b..0624f49 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -7,6 +7,7 @@ class _LegFetchOptions { final String? dateRangeStart; final String? dateRangeEnd; final bool unallocatedOnly; + final List networkFilter; const _LegFetchOptions({ this.limit = 100, @@ -15,6 +16,7 @@ class _LegFetchOptions { this.dateRangeStart, this.dateRangeEnd, this.unallocatedOnly = false, + this.networkFilter = const [], }); } @@ -118,6 +120,7 @@ class DataService extends ChangeNotifier { List _stationNetworks = []; Map> _stationCountryNetworks = {}; DateTime? _stationFiltersFetchedAt; + DateTime? _stationNetworksFetchedAt; List get stationNetworks => _stationNetworks; Map> get stationCountryNetworks => _stationCountryNetworks; @@ -391,9 +394,14 @@ class DataService extends ChangeNotifier { String? dateRangeEnd, bool append = false, bool unallocatedOnly = false, + List networkFilter = const [], }) async { _isLegsLoading = true; if (!append) { + final normalizedNetworks = networkFilter + .map((network) => network.trim()) + .where((network) => network.isNotEmpty) + .toList(); _lastLegsFetch = _LegFetchOptions( limit: limit, sortBy: sortBy, @@ -401,6 +409,7 @@ class DataService extends ChangeNotifier { dateRangeStart: dateRangeStart, dateRangeEnd: dateRangeEnd, unallocatedOnly: unallocatedOnly, + networkFilter: normalizedNetworks, ); } final buffer = StringBuffer( @@ -415,6 +424,13 @@ class DataService extends ChangeNotifier { if (unallocatedOnly) { buffer.write('&unallocated_only=true'); } + final networks = networkFilter + .map((network) => network.trim()) + .where((network) => network.isNotEmpty) + .toList(); + for (final network in networks) { + buffer.write('&network_filter=${Uri.encodeQueryComponent(network)}'); + } try { final json = await api.get('/user/legs${buffer.toString()}'); @@ -444,6 +460,7 @@ class DataService extends ChangeNotifier { dateRangeStart: _lastLegsFetch.dateRangeStart, dateRangeEnd: _lastLegsFetch.dateRangeEnd, unallocatedOnly: _lastLegsFetch.unallocatedOnly, + networkFilter: _lastLegsFetch.networkFilter, ); } @@ -669,6 +686,7 @@ class DataService extends ChangeNotifier { _stationNetworks = []; _stationCountryNetworks = {}; _stationFiltersFetchedAt = null; + _stationNetworksFetchedAt = null; _notifications = []; _isNotificationsLoading = false; _userEntriesVisibility = 'private'; @@ -736,6 +754,9 @@ class DataService extends ChangeNotifier { final networks = (map['networks'] as List? ?? const []) .whereType() .toList(); + networks.sort( + (a, b) => a.toLowerCase().compareTo(b.toLowerCase()), + ); final countryNetworksRaw = map['country_networks'] as Map? ?? const {}; final countryNetworks = >{}; @@ -753,6 +774,31 @@ class DataService extends ChangeNotifier { } } + Future fetchStationNetworks() async { + final now = DateTime.now(); + final recent = _stationNetworks.isNotEmpty && + ((_stationNetworksFetchedAt != null && + now.difference(_stationNetworksFetchedAt!) < + const Duration(minutes: 30)) || + (_stationFiltersFetchedAt != null && + now.difference(_stationFiltersFetchedAt!) < + const Duration(minutes: 30))); + if (recent) return; + try { + final response = await api.get('/stations/networks'); + if (response is List) { + final networks = response.whereType().toList(); + networks.sort( + (a, b) => a.toLowerCase().compareTo(b.toLowerCase()), + ); + _stationNetworks = networks; + _stationNetworksFetchedAt = now; + } + } catch (e) { + debugPrint('Failed to fetch station networks: $e'); + } + } + String _stationKey(List countries, List networks) { final c = countries..sort(); final n = networks..sort(); diff --git a/pubspec.yaml b/pubspec.yaml index 1e2978c..de9b510 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.7.7+15 +version: 0.7.8+16 environment: sdk: ^3.8.1 diff --git a/test/helpers/fake_services.dart b/test/helpers/fake_services.dart index 8617ce3..f1b5624 100644 --- a/test/helpers/fake_services.dart +++ b/test/helpers/fake_services.dart @@ -201,6 +201,7 @@ class FakeDataService extends DataService { String? dateRangeEnd, bool append = false, bool unallocatedOnly = false, + List networkFilter = const [], }) async {} @override @@ -231,6 +232,9 @@ class FakeDataService extends DataService { @override Future fetchPendingLocoCount() async {} + @override + Future fetchStationNetworks() async {} + @override Future?> fetchClassStats(String locoClass) async { return TestData.classStats;