Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 58 additions & 6 deletions lib/features/home/providers/home_order_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<String>>((ref) => []);
final paymentMethodFilterProvider = StateProvider<List<String>>((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<int>((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<int>((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<int>((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<T>(ProviderListenable<T> 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<List<NostrEvent>>((ref) {
final allOrdersAsync = ref.watch(orderEventsProvider);
Expand Down Expand Up @@ -56,12 +108,12 @@ final filteredOrdersProvider = Provider<List<NostrEvent>>((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 &&
Expand All @@ -70,7 +122,7 @@ final filteredOrdersProvider = Provider<List<NostrEvent>>((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;
Expand Down
186 changes: 134 additions & 52 deletions lib/features/home/screens/home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -219,65 +235,131 @@ class HomeScreen extends ConsumerWidget {
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(30),
child: InkWell(
onTap: () {
showDialog<void>(
context: context,
builder: (BuildContext context) {
return const Dialog(
child: OrderFilter(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () {
showDialog<void>(
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,
),
),
),
),
),
),
],
);
}
}
9 changes: 9 additions & 0 deletions lib/l10n/intl_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions lib/l10n/intl_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions lib/l10n/intl_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions lib/l10n/intl_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions lib/l10n/intl_it.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading