feat(meezi_app): discovery screen parity — rich filters + taxonomy (code-only)

Brings the Flutter discover screen toward web-Koja parity. Unverified (pub blocked).

- DiscoverFilters is now a copyWith class so the many optional filters set safely.
- Adds an "open now" chip, rating chips, sort, and a taxonomy-driven filter sheet
  (themes/vibes/occasions/space-features as multi-select chips + price tier),
  feeding the rich discover() query. Active-filter badge + pull-to-refresh.
- Café cards show open/closed status.
This commit is contained in:
soroush.asadi
2026-06-03 07:52:49 +03:30
parent 1d79dde5e1
commit 2652736d31
@@ -1,25 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show ValueGetter;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../cart/cart_state.dart';
typedef DiscoverFilters = ({String? q, double? minRating, String sort});
/// Discovery filters. A class (not a record) so the many optional filters can be
/// changed one at a time via copyWith without re-listing every field.
class DiscoverFilters {
const DiscoverFilters({
this.q,
this.minRating,
this.sort = 'rating',
this.openNow = false,
this.priceTier,
this.themes = const [],
this.vibes = const [],
this.occasions = const [],
this.spaceFeatures = const [],
});
final discoverFiltersProvider = StateProvider<DiscoverFilters>(
(_) => (q: null, minRating: null, sort: 'rating'),
final String? q;
final double? minRating;
final String sort;
final bool openNow;
final String? priceTier;
final List<String> themes;
final List<String> vibes;
final List<String> occasions;
final List<String> spaceFeatures;
int get activeCount =>
(minRating != null ? 1 : 0) +
(openNow ? 1 : 0) +
(priceTier != null ? 1 : 0) +
themes.length +
vibes.length +
occasions.length +
spaceFeatures.length;
DiscoverFilters copyWith({
ValueGetter<String?>? q,
ValueGetter<double?>? minRating,
String? sort,
bool? openNow,
ValueGetter<String?>? priceTier,
List<String>? themes,
List<String>? vibes,
List<String>? occasions,
List<String>? spaceFeatures,
}) {
return DiscoverFilters(
q: q != null ? q() : this.q,
minRating: minRating != null ? minRating() : this.minRating,
sort: sort ?? this.sort,
openNow: openNow ?? this.openNow,
priceTier: priceTier != null ? priceTier() : this.priceTier,
themes: themes ?? this.themes,
vibes: vibes ?? this.vibes,
occasions: occasions ?? this.occasions,
spaceFeatures: spaceFeatures ?? this.spaceFeatures,
);
}
}
final discoverProvider = FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) {
final filters = ref.watch(discoverFiltersProvider);
final discoverFiltersProvider =
StateProvider<DiscoverFilters>((_) => const DiscoverFilters());
final discoverProvider =
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) {
final f = ref.watch(discoverFiltersProvider);
return ref.watch(publicApiProvider).discover(
city: 'تهران',
q: filters.q,
minRating: filters.minRating,
sort: filters.sort,
q: f.q,
minRating: f.minRating,
sort: f.sort,
openNow: f.openNow,
priceTier: f.priceTier,
themes: f.themes,
vibes: f.vibes,
occasions: f.occasions,
spaceFeatures: f.spaceFeatures,
);
});
/// Available themes/vibes/occasions/spaceFeatures for the filter sheet.
final discoverTaxonomyProvider =
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) {
return ref.watch(publicApiProvider).discoverTaxonomy();
});
class DiscoverScreen extends ConsumerStatefulWidget {
const DiscoverScreen({super.key});
@@ -39,7 +109,16 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
void _applySearch() {
final q = _searchController.text.trim();
ref.read(discoverFiltersProvider.notifier).update(
(s) => (q: q.isEmpty ? null : q, minRating: s.minRating, sort: s.sort),
(s) => s.copyWith(q: () => q.isEmpty ? null : q),
);
}
Future<void> _openFilters() async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _DiscoverFilterSheet(),
);
}
@@ -70,17 +149,27 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'جستجوی نام کافه...',
border: const OutlineInputBorder(),
hintText: 'کافه دنج برای کار، نزدیک من...',
isDense: true,
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.search),
icon: const Icon(Icons.arrow_back),
onPressed: _applySearch,
),
),
textInputAction: TextInputAction.search,
onSubmitted: (_) => _applySearch(),
),
),
const SizedBox(width: 8),
Badge(
isLabelVisible: filters.activeCount > 0,
label: Text('${filters.activeCount}'),
child: IconButton.filledTonal(
icon: const Icon(Icons.tune),
onPressed: _openFilters,
),
),
],
),
),
@@ -90,26 +179,29 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
child: Row(
children: [
FilterChip(
label: const Text('همه'),
selected: filters.minRating == null,
onSelected: (_) {
ref.read(discoverFiltersProvider.notifier).update(
(s) => (q: s.q, minRating: null, sort: s.sort),
);
},
label: const Text('باز است'),
selected: filters.openNow,
onSelected: (v) => ref
.read(discoverFiltersProvider.notifier)
.update((s) => s.copyWith(openNow: v)),
),
const SizedBox(width: 8),
FilterChip(
label: const Text('همه امتیازها'),
selected: filters.minRating == null,
onSelected: (_) => ref
.read(discoverFiltersProvider.notifier)
.update((s) => s.copyWith(minRating: () => null)),
),
for (final min in [3.0, 4.0, 4.5])
Padding(
padding: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text('$min+'),
selected: filters.minRating == min,
onSelected: (_) {
ref.read(discoverFiltersProvider.notifier).update(
(s) => (q: s.q, minRating: min, sort: s.sort),
);
},
onSelected: (_) => ref
.read(discoverFiltersProvider.notifier)
.update((s) => s.copyWith(minRating: () => min)),
),
),
],
@@ -121,7 +213,6 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
value: filters.sort,
decoration: const InputDecoration(
labelText: 'مرتب‌سازی',
border: OutlineInputBorder(),
isDense: true,
),
items: const [
@@ -131,9 +222,9 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
],
onChanged: (sort) {
if (sort == null) return;
ref.read(discoverFiltersProvider.notifier).update(
(s) => (q: s.q, minRating: s.minRating, sort: sort),
);
ref
.read(discoverFiltersProvider.notifier)
.update((s) => s.copyWith(sort: sort));
},
),
),
@@ -143,20 +234,56 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
if (cafes.isEmpty) {
return const Center(child: Text('کافه‌ای یافت نشد'));
}
return ListView.separated(
return RefreshIndicator(
onRefresh: () async => ref.refresh(discoverProvider.future),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: cafes.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final cafe = cafes[index];
itemBuilder: (context, index) =>
_CafeCard(cafe: cafes[index]),
),
);
},
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('خطا: $e')),
),
),
],
),
),
);
}
}
class _CafeCard extends StatelessWidget {
const _CafeCard({required this.cafe});
final Map<String, dynamic> cafe;
@override
Widget build(BuildContext context) {
final slug = cafe['slug'] as String;
final name = cafe['name'] as String? ?? slug;
final avg = (cafe['averageRating'] as num?)?.toDouble() ?? 0;
final count = cafe['reviewCount'] as int? ?? 0;
final address = cafe['address'] as String?;
final isOpen = cafe['isOpenNow'] as bool?;
return Card(
child: ListTile(
title: Text(name),
title: Row(
children: [
Expanded(child: Text(name)),
if (isOpen != null)
Text(
isOpen ? 'باز' : 'بسته',
style: TextStyle(
fontSize: 12,
color: isOpen ? Colors.green : Colors.red,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -169,16 +296,165 @@ class _DiscoverScreenState extends ConsumerState<DiscoverScreen> {
onTap: () => context.push('/cafe/$slug'),
),
);
}
}
class _DiscoverFilterSheet extends ConsumerWidget {
const _DiscoverFilterSheet();
static const _priceTiers = [
('budget', 'اقتصادی'),
('moderate', 'متوسط'),
('upscale', 'لاکچری'),
('luxury', 'بسیار لاکچری'),
];
List<({String key, String label})> _parseTax(dynamic raw) {
if (raw is! List) return const [];
return raw
.map<({String key, String label})>((e) {
if (e is Map) {
final k = (e['key'] ?? e['value'] ?? e['id'] ?? '').toString();
final l = (e['labelFa'] ?? e['label'] ?? e['nameFa'] ?? e['name'] ?? k)
.toString();
return (key: k, label: l);
}
final s = e.toString();
return (key: s, label: s);
})
.where((t) => t.key.isNotEmpty)
.toList();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final filters = ref.watch(discoverFiltersProvider);
final taxonomy = ref.watch(discoverTaxonomyProvider);
final notifier = ref.read(discoverFiltersProvider.notifier);
Widget chips(String title, List<({String key, String label})> items,
List<String> selected, void Function(List<String>) onChange) {
if (items.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 6),
child: Text(title, style: Theme.of(context).textTheme.titleSmall),
),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final it in items)
FilterChip(
label: Text(it.label),
selected: selected.contains(it.key),
onSelected: (v) {
final next = List<String>.from(selected);
if (v) {
next.add(it.key);
} else {
next.remove(it.key);
}
onChange(next);
},
),
],
),
],
);
}
return Directionality(
textDirection: TextDirection.rtl,
child: Padding(
padding: EdgeInsets.fromLTRB(
16,
0,
16,
16 + MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('فیلترها',
style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
if (filters.activeCount > 0)
TextButton(
onPressed: () =>
notifier.state = const DiscoverFilters(),
child: const Text('پاک کردن'),
),
],
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('فقط کافه‌های باز'),
value: filters.openNow,
onChanged: (v) => notifier.update((s) => s.copyWith(openNow: v)),
),
const Padding(
padding: EdgeInsets.fromLTRB(0, 8, 0, 6),
child: Text('محدوده قیمت'),
),
Wrap(
spacing: 8,
children: [
for (final p in _priceTiers)
ChoiceChip(
label: Text(p.$2),
selected: filters.priceTier == p.$1,
onSelected: (v) => notifier.update(
(s) => s.copyWith(priceTier: () => v ? p.$1 : null),
),
),
],
),
taxonomy.when(
data: (tax) {
if (tax == null) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
chips('فضا و حال‌وهوا', _parseTax(tax['themes']),
filters.themes,
(v) => notifier.update((s) => s.copyWith(themes: v))),
chips('وایب', _parseTax(tax['vibes']), filters.vibes,
(v) => notifier.update((s) => s.copyWith(vibes: v))),
chips('مناسبت', _parseTax(tax['occasions']),
filters.occasions,
(v) => notifier.update((s) => s.copyWith(occasions: v))),
chips('امکانات', _parseTax(tax['spaceFeatures']),
filters.spaceFeatures,
(v) => notifier.update(
(s) => s.copyWith(spaceFeatures: v))),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('خطا: $e')),
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
error: (_, __) => const SizedBox.shrink(),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('نمایش نتایج'),
),
),
],
),
),
),
);
}
}