diff --git a/lib/features/home/providers/home_order_providers.dart b/lib/features/home/providers/home_order_providers.dart index 59116e411..09938d2cb 100644 --- a/lib/features/home/providers/home_order_providers.dart +++ b/lib/features/home/providers/home_order_providers.dart @@ -7,12 +7,64 @@ import 'package:mostro_mobile/shared/providers/order_repository_provider.dart'; final homeOrderTypeProvider = StateProvider((ref) => OrderType.sell); +// Default values for the order book filters. A filter is considered active +// only when its state differs from these defaults. +const double kDefaultRatingMin = 0.0; +const double kDefaultRatingMax = 5.0; +const double kDefaultPremiumMin = -10.0; +const double kDefaultPremiumMax = 10.0; +const int kDefaultMinDays = 0; + // Filter state providers final currencyFilterProvider = StateProvider>((ref) => []); final paymentMethodFilterProvider = StateProvider>((ref) => []); -final ratingFilterProvider = StateProvider<({double min, double max})>((ref) => (min: 0.0, max: 5.0)); -final premiumRangeFilterProvider = StateProvider<({double min, double max})>((ref) => (min: -10.0, max: 10.0)); -final minDaysFilterProvider = StateProvider((ref) => 0); +final ratingFilterProvider = StateProvider<({double min, double max})>( + (ref) => (min: kDefaultRatingMin, max: kDefaultRatingMax)); +final premiumRangeFilterProvider = StateProvider<({double min, double max})>( + (ref) => (min: kDefaultPremiumMin, max: kDefaultPremiumMax)); +final minDaysFilterProvider = StateProvider((ref) => kDefaultMinDays); + +/// Single definition of "this filter is active", so the count shown in the UI +/// can never disagree with the filtering actually applied. +bool _isMinDaysActive(int minDays) => minDays > kDefaultMinDays; + +bool _isRatingActive(({double min, double max}) range) => + range.min > kDefaultRatingMin || range.max < kDefaultRatingMax; + +bool _isPremiumActive(({double min, double max}) range) => + range.min > kDefaultPremiumMin || range.max < kDefaultPremiumMax; + +/// Number of filters that currently differ from their default value. +final activeFilterCountProvider = Provider((ref) { + final selectedCurrencies = ref.watch(currencyFilterProvider); + final selectedPaymentMethods = ref.watch(paymentMethodFilterProvider); + final ratingRange = ref.watch(ratingFilterProvider); + final premiumRange = ref.watch(premiumRangeFilterProvider); + final minDays = ref.watch(minDaysFilterProvider); + + var count = 0; + if (selectedCurrencies.isNotEmpty) count++; + if (selectedPaymentMethods.isNotEmpty) count++; + if (_isMinDaysActive(minDays)) count++; + if (_isRatingActive(ratingRange)) count++; + if (_isPremiumActive(premiumRange)) count++; + return count; +}); + +/// Signature shared by `WidgetRef.read` and `ProviderContainer.read`, so +/// [clearAllOrderFilters] can be called from widgets and from tests alike. +typedef ProviderReader = T Function(ProviderListenable provider); + +/// Resets every order book filter to its default value. +void clearAllOrderFilters(ProviderReader read) { + read(currencyFilterProvider.notifier).state = []; + read(paymentMethodFilterProvider.notifier).state = []; + read(ratingFilterProvider.notifier).state = + (min: kDefaultRatingMin, max: kDefaultRatingMax); + read(premiumRangeFilterProvider.notifier).state = + (min: kDefaultPremiumMin, max: kDefaultPremiumMax); + read(minDaysFilterProvider.notifier).state = kDefaultMinDays; +} final filteredOrdersProvider = Provider>((ref) { final allOrdersAsync = ref.watch(orderEventsProvider); @@ -56,12 +108,12 @@ final filteredOrdersProvider = Provider>((ref) { } // Apply minimum days filter (maker's account age as reported in rating.days) - if (minDays > 0) { + if (_isMinDaysActive(minDays)) { filtered = filtered.where((o) => (o.rating?.days ?? 0) >= minDays); } // Apply rating filter - if (ratingRange.min > 0.0 || ratingRange.max < 5.0) { + if (_isRatingActive(ratingRange)) { filtered = filtered.where((o) => o.rating != null && o.rating!.totalRating >= ratingRange.min && @@ -70,7 +122,7 @@ final filteredOrdersProvider = Provider>((ref) { } // Apply premium/discount filter - if (premiumRange.min > -10.0 || premiumRange.max < 10.0) { + if (_isPremiumActive(premiumRange)) { filtered = filtered.where((o) { if (o.premium == null || o.premium!.isEmpty) return false; final premiumValue = double.tryParse(o.premium!) ?? 0.0; diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart index 0d8acb21d..ce2e151c1 100644 --- a/lib/features/home/screens/home_screen.dart +++ b/lib/features/home/screens/home_screen.dart @@ -197,6 +197,16 @@ class HomeScreen extends ConsumerWidget { Widget _buildFilterButton(BuildContext context, WidgetRef ref) { final filteredOrders = ref.watch(filteredOrdersProvider); + final activeFilterCount = ref.watch(activeFilterCountProvider); + final hasFilters = activeFilterCount > 0; + + // When filters are active the pill is highlighted so the user can tell at a + // glance that the offers list is being narrowed down. + final foregroundColor = + hasFilters ? AppTheme.mostroGreen : Colors.white70; + final dividerColor = hasFilters + ? AppTheme.mostroGreen.withValues(alpha: 0.4) + : Colors.white.withValues(alpha: 0.2); return Container( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), @@ -205,9 +215,15 @@ class HomeScreen extends ConsumerWidget { alignment: Alignment.centerLeft, child: Container( decoration: BoxDecoration( - color: AppTheme.backgroundInput, + color: hasFilters + ? AppTheme.mostroGreen.withValues(alpha: 0.12) + : AppTheme.backgroundInput, borderRadius: BorderRadius.circular(30), - border: Border.all(color: Colors.white.withValues(alpha: 0.05)), + border: Border.all( + color: hasFilters + ? AppTheme.mostroGreen.withValues(alpha: 0.6) + : Colors.white.withValues(alpha: 0.05), + ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.2), @@ -219,65 +235,131 @@ class HomeScreen extends ConsumerWidget { child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(30), - child: InkWell( - onTap: () { - showDialog( - context: context, - builder: (BuildContext context) { - return const Dialog( - child: OrderFilter(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) { + return const Dialog( + child: OrderFilter(), + ); + }, ); }, - ); - }, - borderRadius: BorderRadius.circular(30), - splashColor: AppTheme.activeColor.withValues(alpha: 0.3), - highlightColor: AppTheme.activeColor.withValues(alpha: 0.15), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const HeroIcon( - HeroIcons.funnel, - style: HeroIconStyle.outline, - color: Colors.white70, - size: 18, - ), - const SizedBox(width: 8), - Text( - S.of(context)!.filter, - style: const TextStyle( - color: Colors.white70, - fontSize: 13, - fontWeight: FontWeight.w500, - letterSpacing: 0.5, - ), + borderRadius: BorderRadius.circular(30), + splashColor: AppTheme.activeColor.withValues(alpha: 0.3), + highlightColor: AppTheme.activeColor.withValues(alpha: 0.15), + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: hasFilters ? 8 : 16, + top: 12, + bottom: 12, ), - Container( - margin: const EdgeInsets.symmetric(horizontal: 8), - height: 16, - width: 1, - color: Colors.white.withValues(alpha: 0.2), - ), - Text( - S - .of(context)! - .offersCount(filteredOrders.length.toString()), - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - fontWeight: FontWeight.normal, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + HeroIcon( + HeroIcons.funnel, + style: hasFilters + ? HeroIconStyle.solid + : HeroIconStyle.outline, + color: foregroundColor, + size: 18, + ), + const SizedBox(width: 8), + Text( + hasFilters + ? S + .of(context)! + .filterWithCount(activeFilterCount.toString()) + : S.of(context)!.filter, + style: TextStyle( + color: foregroundColor, + fontSize: 13, + fontWeight: + hasFilters ? FontWeight.w600 : FontWeight.w500, + letterSpacing: 0.5, + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + height: 16, + width: 1, + color: dividerColor, + ), + Text( + S + .of(context)! + .offersCount(filteredOrders.length.toString()), + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + fontWeight: FontWeight.normal, + ), + ), + ], ), - ], + ), ), - ), + if (hasFilters) + _buildClearFiltersButton(context, ref, dividerColor), + ], ), ), ), ), ); } + + /// Quick "clear all filters" action shown inside the filter pill. + /// + /// It sits outside the pill's main [InkWell] so tapping it resets the filters + /// without also opening the filter dialog. + Widget _buildClearFiltersButton( + BuildContext context, + WidgetRef ref, + Color dividerColor, + ) { + final label = S.of(context)!.clearFilters; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 16, + width: 1, + color: dividerColor, + ), + Semantics( + button: true, + label: label, + child: Tooltip( + message: label, + child: InkWell( + onTap: () => clearAllOrderFilters(ref.read), + borderRadius: BorderRadius.circular(30), + splashColor: AppTheme.mostroGreen.withValues(alpha: 0.3), + highlightColor: AppTheme.mostroGreen.withValues(alpha: 0.15), + child: const SizedBox( + width: 40, + height: 40, + child: Center( + child: HeroIcon( + HeroIcons.xMark, + style: HeroIconStyle.outline, + color: AppTheme.mostroGreen, + size: 16, + ), + ), + ), + ), + ), + ), + ], + ); + } } diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index a60934bbb..d35ef18e6 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -217,6 +217,15 @@ } } }, + "filterWithCount": "FILTER ({count})", + "@filterWithCount": { + "placeholders": { + "count": { + "type": "String" + } + } + }, + "clearFilters": "Filter zurücksetzen", "creatingNewOrder": "NEUE ORDER ERSTELLEN", "enterSatsAmountBuy": "Gib den Sats-Betrag ein, den du kaufen möchtest", "enterSatsAmountSell": "Gib den Sats-Betrag ein, den du verkaufen möchtest", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index ad67dda2f..c6d7e8346 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -217,6 +217,15 @@ } } }, + "filterWithCount": "FILTER ({count})", + "@filterWithCount": { + "placeholders": { + "count": { + "type": "String" + } + } + }, + "clearFilters": "Clear filters", "creatingNewOrder": "CREATING NEW ORDER", "enterSatsAmountBuy": "Enter the Sats amount you want to Buy", "enterSatsAmountSell": "Enter the Sats amount you want to Sell", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index e8c1bcbe1..903073c04 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -181,6 +181,15 @@ } } }, + "filterWithCount": "FILTRO ({count})", + "@filterWithCount": { + "placeholders": { + "count": { + "type": "String" + } + } + }, + "clearFilters": "Limpiar filtros", "creatingNewOrder": "CREANDO NUEVA ORDEN", "enterSatsAmountBuy": "Ingresa la cantidad de Sats que quieres Comprar", "enterSatsAmountSell": "Ingresa la cantidad de Sats que quieres Vender", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 6d6f1980c..a61d24c2a 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -217,6 +217,15 @@ } } }, + "filterWithCount": "FILTRE ({count})", + "@filterWithCount": { + "placeholders": { + "count": { + "type": "String" + } + } + }, + "clearFilters": "Effacer les filtres", "creatingNewOrder": "CRÉATION D'UNE NOUVELLE COMMANDE", "enterSatsAmountBuy": "Entrez le montant de Sats que vous voulez acheter", "enterSatsAmountSell": "Entrez le montant de Sats que vous voulez vendre", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 371a1b4a2..dee5fe3e0 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -216,6 +216,15 @@ } } }, + "filterWithCount": "FILTRO ({count})", + "@filterWithCount": { + "placeholders": { + "count": { + "type": "String" + } + } + }, + "clearFilters": "Rimuovi filtri", "creatingNewOrder": "CREAZIONE NUOVO ORDINE", "enterSatsAmountBuy": "Inserisci la quantità di Sats che vuoi Comprare", "enterSatsAmountSell": "Inserisci la quantità di Sats che vuoi Vendere", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 61ef03a77..469ec6816 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -217,6 +217,15 @@ } } }, + "filterWithCount": "FILTRAR ({count})", + "@filterWithCount": { + "placeholders": { + "count": { + "type": "String" + } + } + }, + "clearFilters": "Limpar filtros", "creatingNewOrder": "CRIANDO NOVA ORDEM", "enterSatsAmountBuy": "Digite a quantidade de Sats que você quer Comprar", "enterSatsAmountSell": "Digite a quantidade de Sats que você quer Vender", diff --git a/lib/shared/widgets/order_filter.dart b/lib/shared/widgets/order_filter.dart index e9c7e85c0..d08d266b9 100644 --- a/lib/shared/widgets/order_filter.dart +++ b/lib/shared/widgets/order_filter.dart @@ -250,12 +250,13 @@ class OrderFilter extends ConsumerStatefulWidget { class OrderFilterState extends ConsumerState { List selectedFiatCurrencies = []; List selectedPaymentMethods = []; - double ratingMin = 0.0; - double ratingMax = 5.0; - double premiumMin = -10.0; - double premiumMax = 10.0; - int minDays = 0; - final TextEditingController _daysController = TextEditingController(text: '0'); + double ratingMin = kDefaultRatingMin; + double ratingMax = kDefaultRatingMax; + double premiumMin = kDefaultPremiumMin; + double premiumMax = kDefaultPremiumMax; + int minDays = kDefaultMinDays; + final TextEditingController _daysController = + TextEditingController(text: kDefaultMinDays.toString()); // Options for the multi-select fields. @@ -571,8 +572,8 @@ class OrderFilterState extends ConsumerState { ), child: RangeSlider( values: RangeValues(premiumMin, premiumMax), - min: -10.0, - max: 10.0, + min: kDefaultPremiumMin, + max: kDefaultPremiumMax, divisions: 20, labels: RangeLabels( "${premiumMin.toInt()}%", @@ -651,8 +652,8 @@ class OrderFilterState extends ConsumerState { ), child: RangeSlider( values: RangeValues(ratingMin, ratingMax), - min: 0.0, - max: 5.0, + min: kDefaultRatingMin, + max: kDefaultRatingMax, divisions: 5, labels: RangeLabels( ratingMin.toInt().toString(), @@ -800,7 +801,9 @@ class OrderFilterState extends ConsumerState { onChanged: (text) { final parsed = int.tryParse(text); setState(() { - minDays = parsed == null ? 0 : parsed.clamp(0, 9999); + minDays = parsed == null + ? kDefaultMinDays + : parsed.clamp(0, 9999); }); }, ), @@ -827,20 +830,17 @@ class OrderFilterState extends ConsumerState { setState(() { selectedFiatCurrencies.clear(); selectedPaymentMethods.clear(); - ratingMin = 0.0; - ratingMax = 5.0; - premiumMin = -10.0; - premiumMax = 10.0; - minDays = 0; - _daysController.text = '0'; + ratingMin = kDefaultRatingMin; + ratingMax = kDefaultRatingMax; + premiumMin = kDefaultPremiumMin; + premiumMax = kDefaultPremiumMax; + minDays = kDefaultMinDays; + _daysController.text = kDefaultMinDays.toString(); }); - ref.read(currencyFilterProvider.notifier).state = []; - ref.read(paymentMethodFilterProvider.notifier).state = []; - ref.read(ratingFilterProvider.notifier).state = (min: 0.0, max: 5.0); - ref.read(premiumRangeFilterProvider.notifier).state = (min: -10.0, max: 10.0); - ref.read(minDaysFilterProvider.notifier).state = 0; - + clearAllOrderFilters(ref.read); + + Navigator.of(context).pop(); }, style: OutlinedButton.styleFrom( diff --git a/test/features/home/home_order_providers_test.dart b/test/features/home/home_order_providers_test.dart new file mode 100644 index 000000000..533e371c9 --- /dev/null +++ b/test/features/home/home_order_providers_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/features/home/providers/home_order_providers.dart'; + +void main() { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(); + }); + + tearDown(() { + container.dispose(); + }); + + int activeCount() => container.read(activeFilterCountProvider); + + group('activeFilterCountProvider', () { + test('is zero when no filter has been touched', () { + expect(activeCount(), 0); + }); + + test('counts a currency selection', () { + container.read(currencyFilterProvider.notifier).state = ['USD']; + expect(activeCount(), 1); + }); + + test('counts a payment method selection', () { + container.read(paymentMethodFilterProvider.notifier).state = ['Bank']; + expect(activeCount(), 1); + }); + + test('counts a minimum days filter above the default', () { + container.read(minDaysFilterProvider.notifier).state = 5; + expect(activeCount(), 1); + }); + + test('does not count minimum days left at the default', () { + container.read(minDaysFilterProvider.notifier).state = kDefaultMinDays; + expect(activeCount(), 0); + }); + + test('counts a rating range narrowed only on the lower bound', () { + container.read(ratingFilterProvider.notifier).state = + (min: 3.0, max: kDefaultRatingMax); + expect(activeCount(), 1); + }); + + test('counts a rating range narrowed only on the upper bound', () { + container.read(ratingFilterProvider.notifier).state = + (min: kDefaultRatingMin, max: 4.0); + expect(activeCount(), 1); + }); + + test('stops counting a rating range restored to its defaults', () { + final notifier = container.read(ratingFilterProvider.notifier); + notifier.state = (min: 3.0, max: 4.0); + expect(activeCount(), 1); + + notifier.state = (min: kDefaultRatingMin, max: kDefaultRatingMax); + expect(activeCount(), 0); + }); + + test('counts a premium range narrowed only on the lower bound', () { + container.read(premiumRangeFilterProvider.notifier).state = + (min: -5.0, max: kDefaultPremiumMax); + expect(activeCount(), 1); + }); + + test('counts a premium range narrowed only on the upper bound', () { + container.read(premiumRangeFilterProvider.notifier).state = + (min: kDefaultPremiumMin, max: 5.0); + expect(activeCount(), 1); + }); + + test('stops counting a premium range restored to its defaults', () { + final notifier = container.read(premiumRangeFilterProvider.notifier); + notifier.state = (min: -5.0, max: 5.0); + expect(activeCount(), 1); + + notifier.state = (min: kDefaultPremiumMin, max: kDefaultPremiumMax); + expect(activeCount(), 0); + }); + + test('adds up every active filter', () { + container.read(currencyFilterProvider.notifier).state = ['USD', 'EUR']; + container.read(paymentMethodFilterProvider.notifier).state = ['Bank']; + container.read(minDaysFilterProvider.notifier).state = 10; + container.read(ratingFilterProvider.notifier).state = (min: 3.0, max: 5.0); + container.read(premiumRangeFilterProvider.notifier).state = + (min: -2.0, max: 2.0); + + expect(activeCount(), 5); + }); + }); + + group('clearAllOrderFilters', () { + test('restores every filter to its default value', () { + container.read(currencyFilterProvider.notifier).state = ['USD']; + container.read(paymentMethodFilterProvider.notifier).state = ['Bank']; + container.read(minDaysFilterProvider.notifier).state = 10; + container.read(ratingFilterProvider.notifier).state = (min: 3.0, max: 4.0); + container.read(premiumRangeFilterProvider.notifier).state = + (min: -2.0, max: 2.0); + expect(activeCount(), 5); + + clearAllOrderFilters(container.read); + + expect(activeCount(), 0); + expect(container.read(currencyFilterProvider), isEmpty); + expect(container.read(paymentMethodFilterProvider), isEmpty); + expect(container.read(minDaysFilterProvider), kDefaultMinDays); + expect(container.read(ratingFilterProvider), + (min: kDefaultRatingMin, max: kDefaultRatingMax)); + expect(container.read(premiumRangeFilterProvider), + (min: kDefaultPremiumMin, max: kDefaultPremiumMax)); + }); + + test('is a no-op when no filter is active', () { + clearAllOrderFilters(container.read); + expect(activeCount(), 0); + }); + }); +}