From d5d204dd194c78391ebd0db84392ab9399b3de16 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Mon, 22 Dec 2025 23:16:54 +0000 Subject: [PATCH] add filter panel to calculator --- lib/components/calculator/calculator.dart | 268 ++++++++++++++++-- .../pages/traction/traction_page.dart | 19 +- lib/components/traction/traction_card.dart | 87 ++++-- .../data_service/data_service_core.dart | 77 +++-- lib/ui/app_shell.dart | 11 +- pubspec.yaml | 2 +- 6 files changed, 368 insertions(+), 96 deletions(-) diff --git a/lib/components/calculator/calculator.dart b/lib/components/calculator/calculator.dart index c0cbaaa..7375692 100644 --- a/lib/components/calculator/calculator.dart +++ b/lib/components/calculator/calculator.dart @@ -133,6 +133,11 @@ class RouteCalculator extends StatefulWidget { class _RouteCalculatorState extends State { List allStations = []; + List _networks = []; + List _countries = []; + List _selectedNetworks = []; + List _selectedCountries = []; + bool _loadingStations = false; RouteResult? _routeResult; RouteResult? get result => _routeResult; @@ -150,14 +155,31 @@ class _RouteCalculatorState extends State { } WidgetsBinding.instance.addPostFrameCallback((_) async { final data = context.read(); - final result = await data.fetchStations(); - if (mounted) { - setState(() => allStations = result); - } + await data.fetchStationFilters(); + if (!mounted) return; + setState(() { + _networks = data.stationNetworks; + _countries = data.stationCountryNetworks.keys.toList(); + }); + await _loadStations(); }); } } + Future _loadStations() async { + setState(() => _loadingStations = true); + final data = context.read(); + final stations = await data.fetchStations( + countries: _selectedCountries, + networks: _selectedNetworks, + ); + if (!mounted) return; + setState(() { + allStations = stations; + _loadingStations = false; + }); + } + Future _calculateRoute(List stations) async { setState(() { _errorMessage = null; @@ -215,6 +237,43 @@ class _RouteCalculatorState extends State { final data = context.watch(); return Column( children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Wrap( + spacing: 12, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _MultiSelectFilter( + label: 'Countries', + options: _countries, + selected: _selectedCountries, + onChanged: (vals) { + setState(() => _selectedCountries = vals); + _loadStations(); + }, + ), + _MultiSelectFilter( + label: 'Networks', + options: _networks, + selected: _selectedNetworks, + onChanged: (vals) { + setState(() => _selectedNetworks = vals); + _loadStations(); + }, + ), + if (_loadingStations) + const Padding( + padding: EdgeInsets.only(left: 8.0), + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ), + ), Expanded( child: ReorderableListView( buildDefaultDragHandles: false, @@ -300,32 +359,27 @@ class _RouteCalculatorState extends State { else SizedBox.shrink(), const SizedBox(height: 10), - LayoutBuilder( - builder: (context, constraints) { - double screenWidth = constraints.maxWidth; - - return Padding( - padding: EdgeInsets.only(right: screenWidth < 450 ? 70 : 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton.icon( - icon: const Icon(Icons.add), - label: const Text('Add Station'), - onPressed: _addStation, - ), - const SizedBox(width: 16), - ElevatedButton.icon( - icon: const Icon(Icons.route), - label: const Text('Calculate Route'), - onPressed: () async { - await _calculateRoute(data.stations); - }, - ), - ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 12, + runSpacing: 8, + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text('Add Station'), + onPressed: _addStation, ), - ); - }, + ElevatedButton.icon( + icon: const Icon(Icons.route), + label: const Text('Calculate Route'), + onPressed: () async { + await _calculateRoute(data.stations); + }, + ), + ], + ), ), const SizedBox(height: 16), @@ -350,3 +404,159 @@ 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/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index f0dc10e..3c7e6c2 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -513,21 +513,10 @@ class _TractionPageState extends State { if (widget.selectionMode) { return Scaffold( appBar: AppBar( - leadingWidth: 140, - leading: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: TextButton.icon( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.arrow_back), - label: const Text('Back'), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - foregroundColor: Theme.of(context).colorScheme.onSurface, - ), - ), + leadingWidth: 56, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), ), title: null, ), diff --git a/lib/components/traction/traction_card.dart b/lib/components/traction/traction_card.dart index f165236..9bf9118 100644 --- a/lib/components/traction/traction_card.dart +++ b/lib/components/traction/traction_card.dart @@ -82,39 +82,68 @@ class TractionCard extends StatelessWidget { ], ), 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'), - ), - if (hasMileageOrTrips && onOpenLegs != null) ...[ - const SizedBox(width: 8), + LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 520; + final buttons = [ TextButton.icon( - onPressed: onOpenLegs, - icon: const Icon(Icons.view_list), - label: const Text('Legs'), + onPressed: onShowInfo, + icon: const Icon(Icons.info_outline), + label: const Text('Details'), ), - ], - const Spacer(), - if (selectionMode && onToggleSelect != null) TextButton.icon( - onPressed: onToggleSelect, - icon: Icon( - isSelected - ? Icons.remove_circle_outline - : Icons.add_circle_outline, + onPressed: onOpenTimeline, + icon: const Icon(Icons.timeline), + label: const Text('Timeline'), + ), + if (hasMileageOrTrips && onOpenLegs != null) + TextButton.icon( + onPressed: onOpenLegs, + icon: const Icon(Icons.view_list), + label: const Text('Legs'), ), - label: Text(isSelected ? 'Remove' : 'Add to entry'), - ), - ], + ]; + + final addButton = 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'), + ) + : null; + + if (isNarrow) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 4, + children: buttons, + ), + if (addButton != null) ...[ + const SizedBox(height: 6), + addButton, + ], + ], + ); + } + + return Row( + children: [ + ...buttons.expand((btn) sync* { + yield btn; + yield const SizedBox(width: 8); + }).take(buttons.length * 2 - 1), + const Spacer(), + if (addButton != null) addButton, + ], + ); + }, ), Wrap( spacing: 8, diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index eb06667..4079657 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -72,9 +72,14 @@ class DataService extends ChangeNotifier { bool get isEventFieldsLoading => _isEventFieldsLoading; // Station Data - List? _cachedStations; - DateTime? _stationsFetchedAt; - Future>? _stationsInFlight; + final Map> _stationCache = {}; + final Map>?> _stationInFlightByKey = {}; + List _stationNetworks = []; + Map> _stationCountryNetworks = {}; + DateTime? _stationFiltersFetchedAt; + List get stationNetworks => _stationNetworks; + Map> get stationCountryNetworks => + _stationCountryNetworks; List stations = [""]; @@ -365,37 +370,75 @@ class DataService extends ChangeNotifier { 0; } - Future> fetchStations() async { + Future fetchStationFilters() 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!; + if (_stationFiltersFetchedAt != null && + now.difference(_stationFiltersFetchedAt!) < const Duration(minutes: 30) && + _stationNetworks.isNotEmpty) { + return; } + try { + final response = await api.get('/stations/filter'); + if (response is List && response.isNotEmpty && response.first is Map) { + final map = Map.from(response.first as Map); + final networks = (map['networks'] as List? ?? const []) + .whereType() + .toList(); + final countryNetworksRaw = + map['country_networks'] as Map? ?? const {}; + final countryNetworks = >{}; + countryNetworksRaw.forEach((key, value) { + if (value is List) { + countryNetworks[key] = value.whereType().toList(); + } + }); + _stationNetworks = networks; + _stationCountryNetworks = countryNetworks; + _stationFiltersFetchedAt = now; + } + } catch (e) { + debugPrint('Failed to fetch station filters: $e'); + } + } - if (_stationsInFlight != null) return _stationsInFlight!; + String _stationKey(List countries, List networks) { + final c = countries..sort(); + final n = networks..sort(); + return 'c:${c.join('|')};n:${n.join('|')}'; + } - _stationsInFlight = () async { + Future> fetchStations({ + List countries = const [], + List networks = const [], + }) async { + final key = _stationKey(List.from(countries), List.from(networks)); + + if (_stationCache.containsKey(key)) return _stationCache[key]!; + final inflight = _stationInFlightByKey[key]; + if (inflight != null) return inflight; + + final future = () async { try { - final response = await api.get('/location'); + final response = await api.post('/location', { + 'countries_filter': countries, + 'network_filter': networks, + }); if (response is! List) return const []; final parsed = response .whereType() .map((e) => Station.fromJson(Map.from(e))) .toList(); - _cachedStations = parsed; - _stationsFetchedAt = now; + _stationCache[key] = parsed; return parsed; } catch (e) { debugPrint('Failed to fetch stations: $e'); return const []; } finally { - _stationsInFlight = null; + _stationInFlightByKey.remove(key); } }(); - return _stationsInFlight!; + _stationInFlightByKey[key] = future; + return future; } } diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index 366cce8..6034bfa 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -84,8 +84,9 @@ class _MyAppState extends State { redirect: (context, state) { final loggedIn = auth.isLoggedIn; final loggingIn = state.uri.toString() == '/login'; + final atSettings = state.uri.toString() == '/settings'; - if (!loggedIn && !loggingIn) return '/login'; + if (!loggedIn && !loggingIn && !atSettings) return '/login'; if (loggedIn && loggingIn) return '/'; return null; }, @@ -155,13 +156,13 @@ class _MyAppState extends State { return NewEntryPage(editLegId: legId); }, ), - GoRoute( - path: '/settings', - builder: (context, state) => const SettingsPage(), - ), ], ), GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), ], ); } diff --git a/pubspec.yaml b/pubspec.yaml index 03faeab..92f8787 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.3.2+1 +version: 0.3.3+1 environment: sdk: ^3.8.1