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'; /// 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 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; 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: 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}); @override ConsumerState createState() => _DiscoverScreenState(); } class _DiscoverScreenState extends ConsumerState { final _searchController = TextEditingController(); @override void dispose() { _searchController.dispose(); super.dispose(); } void _applySearch() { final q = _searchController.text.trim(); ref.read(discoverFiltersProvider.notifier).update( (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); final filters = ref.watch(discoverFiltersProvider); return Directionality( textDirection: TextDirection.rtl, child: Scaffold( appBar: AppBar( title: const Text('کافه‌یاب میزی'), actions: [ IconButton( icon: const Icon(Icons.qr_code_scanner), onPressed: () => context.push('/qr'), ), ], ), body: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Row( children: [ Expanded( child: TextField( controller: _searchController, decoration: InputDecoration( hintText: 'کافه دنج برای کار، نزدیک من...', isDense: true, prefixIcon: const Icon(Icons.search), suffixIcon: IconButton( 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, ), ), ], ), ), SingleChildScrollView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ FilterChip( 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(right: 8), child: FilterChip( label: Text('★ $min+'), selected: filters.minRating == min, onSelected: (_) => ref .read(discoverFiltersProvider.notifier) .update((s) => s.copyWith(minRating: () => min)), ), ), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: DropdownButtonFormField( value: filters.sort, decoration: const InputDecoration( labelText: 'مرتب‌سازی', isDense: true, ), items: const [ DropdownMenuItem(value: 'rating', child: Text('بیشترین امتیاز')), DropdownMenuItem(value: 'reviews', child: Text('بیشترین نظر')), DropdownMenuItem(value: 'name', child: Text('نام')), ], onChanged: (sort) { if (sort == null) return; ref .read(discoverFiltersProvider.notifier) .update((s) => s.copyWith(sort: sort)); }, ), ), Expanded( child: cafesAsync.when( data: (cafes) { if (cafes.isEmpty) { return const Center(child: Text('کافه‌ای یافت نشد')); } 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()), error: (e, _) => Center(child: Text('خطا: $e')), ), ), ], ), ), ); } } 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('نمایش نتایج'), ), ), ], ), ), ), ); } }