diff --git a/mobile/meezi_app/lib/features/discover/discover_screen.dart b/mobile/meezi_app/lib/features/discover/discover_screen.dart index 729e689..9f4ac46 100644 --- a/mobile/meezi_app/lib/features/discover/discover_screen.dart +++ b/mobile/meezi_app/lib/features/discover/discover_screen.dart @@ -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( - (_) => (q: null, minRating: null, sort: 'rating'), -); + final String? q; + final double? minRating; + final String sort; + final bool openNow; + final String? priceTier; + final List themes; + final List vibes; + final List occasions; + final List spaceFeatures; -final discoverProvider = FutureProvider.autoDispose>>((ref) { - final filters = ref.watch(discoverFiltersProvider); + 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? q, + ValueGetter? minRating, + String? sort, + bool? openNow, + ValueGetter? priceTier, + List? themes, + List? vibes, + List? occasions, + List? 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 discoverFiltersProvider = + StateProvider((_) => const DiscoverFilters()); + +final discoverProvider = + FutureProvider.autoDispose>>((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?>((ref) { + return ref.watch(publicApiProvider).discoverTaxonomy(); +}); + class DiscoverScreen extends ConsumerStatefulWidget { const DiscoverScreen({super.key}); @@ -39,10 +109,19 @@ class _DiscoverScreenState extends ConsumerState { 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 _openFilters() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => const _DiscoverFilterSheet(), + ); + } + @override Widget build(BuildContext context) { final cafesAsync = ref.watch(discoverProvider); @@ -70,17 +149,27 @@ class _DiscoverScreenState extends ConsumerState { 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 { 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 { value: filters.sort, decoration: const InputDecoration( labelText: 'مرتب‌سازی', - border: OutlineInputBorder(), isDense: true, ), items: const [ @@ -131,9 +222,9 @@ class _DiscoverScreenState extends ConsumerState { ], 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,36 +234,19 @@ class _DiscoverScreenState extends ConsumerState { if (cafes.isEmpty) { return const Center(child: Text('کافه‌ای یافت نشد')); } - return ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: cafes.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final cafe = cafes[index]; - 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?; - return Card( - child: ListTile( - title: Text(name), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(cafe['city'] as String? ?? ''), - if (address != null && address.isNotEmpty) Text(address), - Text('★ ${avg.toStringAsFixed(1)} · $count نظر'), - ], - ), - trailing: const Icon(Icons.chevron_left), - onTap: () => context.push('/cafe/$slug'), - ), - ); - }, + 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) => + _CafeCard(cafe: cafes[index]), + ), ); }, - loading: () => const Center(child: CircularProgressIndicator()), + loading: () => + const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('خطا: $e')), ), ), @@ -182,3 +256,205 @@ class _DiscoverScreenState extends ConsumerState { ); } } + +class _CafeCard extends StatelessWidget { + const _CafeCard({required this.cafe}); + final Map 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: 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: [ + Text(cafe['city'] as String? ?? ''), + if (address != null && address.isNotEmpty) Text(address), + Text('★ ${avg.toStringAsFixed(1)} · $count نظر'), + ], + ), + trailing: const Icon(Icons.chevron_left), + 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 selected, void Function(List) 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.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 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('نمایش نتایج'), + ), + ), + ], + ), + ), + ), + ); + } +}