From b9d8192d011cae64aae4d90c79d7e63d4b840a5d Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:13:24 +0700 Subject: [PATCH] Move recommendations behind search controls --- lib/app/router/app_router.dart | 5 +- .../mapflow/presentation/mapflow_shell.dart | 206 ++++++++++-------- .../widgets/place_photo_card.dart | 5 +- 3 files changed, 116 insertions(+), 100 deletions(-) diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 8805248..560ded7 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -19,10 +19,7 @@ GoRouter createAppRouter() { return GoRouter( errorBuilder: (context, state) => const MapflowShell(), routes: [ - GoRoute( - path: '/', - builder: (context, state) => const MapflowShell(), - ), + GoRoute(path: '/', builder: (context, state) => const MapflowShell()), GoRoute( path: '/experience/new', pageBuilder: (context, state) { diff --git a/lib/features/mapflow/presentation/mapflow_shell.dart b/lib/features/mapflow/presentation/mapflow_shell.dart index af6f75f..403a05c 100644 --- a/lib/features/mapflow/presentation/mapflow_shell.dart +++ b/lib/features/mapflow/presentation/mapflow_shell.dart @@ -69,6 +69,11 @@ class _MapContent extends StatelessWidget { for (final trait in PlaceTrait.values) if ((traitCounts[trait] ?? 0) > 0) trait, ]; + final hasVoiceRecommendation = + state.voiceFilterTranscript.isNotEmpty || + state.voiceFilterTags.isNotEmpty; + final hasRecommendationSurface = + hasVoiceRecommendation || state.selectedTrait != null; return Scaffold( body: Stack( @@ -122,18 +127,6 @@ class _MapContent extends StatelessWidget { ), ), ), - SafeArea( - child: Align( - alignment: Alignment.topCenter, - child: _VoiceFilterButton( - transcript: state.voiceFilterTranscript, - onApply: placeCubit.recommendPlacesByVoice, - onClear: state.voiceFilterTranscript.isEmpty - ? null - : placeCubit.clearVoiceFilter, - ), - ), - ), SafeArea( child: Align( alignment: Alignment.topRight, @@ -152,16 +145,21 @@ class _MapContent extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (availableTraits.isNotEmpty) - _TraitBar( - selectedTrait: state.selectedTrait, - traits: availableTraits, - traitCounts: traitCounts, + if (hasRecommendationSurface) + _PlaceCarousel( + places: state.recommendations, + onSelect: (place) => placeCubit.selectPlace(place.id), + onFavoriteToggle: placeCubit.toggleFavorite, ), - _PlaceCarousel( - places: state.recommendations, - onSelect: (place) => placeCubit.selectPlace(place.id), - onFavoriteToggle: placeCubit.toggleFavorite, + _RecommendationControls( + active: hasRecommendationSurface, + traits: availableTraits, + selectedTrait: state.selectedTrait, + traitCounts: traitCounts, + onVoice: placeCubit.recommendPlacesByVoice, + onClear: hasVoiceRecommendation + ? () => unawaited(placeCubit.clearVoiceFilter()) + : placeCubit.clearTrait, ), ], ), @@ -337,68 +335,72 @@ class _AddReviewButton extends StatelessWidget { } } -class _VoiceFilterButton extends StatelessWidget { - const _VoiceFilterButton({ - required this.transcript, - required this.onApply, +class _RecommendationControls extends StatelessWidget { + const _RecommendationControls({ + required this.active, + required this.traits, + required this.selectedTrait, + required this.traitCounts, + required this.onVoice, required this.onClear, }); - final String transcript; + final bool active; + final List traits; + final PlaceTrait? selectedTrait; + final Map traitCounts; final Future Function({ required String audioContentBase64, required String audioMimeType, }) - onApply; - final Future Function()? onClear; + onVoice; + final VoidCallback onClear; @override Widget build(BuildContext context) { final tokens = context.mapflowTokens; - final label = transcript.isEmpty ? 'Голосом' : transcript; return Padding( - padding: const EdgeInsets.only(top: 8, left: 70, right: 70), + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), child: Material( color: tokens.mapPanel, borderRadius: BorderRadius.circular(tokens.panelRadius), - child: InkWell( - onTap: () => _openSearch(context), - borderRadius: BorderRadius.circular(tokens.panelRadius), - child: SizedBox( - height: 44, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 12), - const Icon(Icons.mic_none_rounded, size: 18), - const SizedBox(width: 8), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w800), - ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: FilledButton.icon( + onPressed: () => _openVoice(context), + icon: const Icon(Icons.mic_none_rounded, size: 18), + label: const Text('Голосом'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: traits.isEmpty ? null : () => _openTraits(context), + icon: const Icon(Icons.tune_rounded, size: 18), + label: const Text('Теги'), + ), + ), + if (active) ...[ + const SizedBox(width: 4), + IconButton( + onPressed: onClear, + icon: const Icon(Icons.close_rounded), + tooltip: 'Сбросить', ), - if (onClear != null) ...[ - const SizedBox(width: 2), - IconButton( - onPressed: () => onClear!(), - icon: const Icon(Icons.close, size: 18), - tooltip: 'Сбросить', - ), - ] else - const SizedBox(width: 12), ], - ), + ], ), ), ), ); } - Future _openSearch(BuildContext context) async { + Future _openVoice(BuildContext context) async { final result = await showDialog<_VoiceFilterAudio>( context: context, builder: (_) => const _VoiceFilterDialog(), @@ -406,11 +408,34 @@ class _VoiceFilterButton extends StatelessWidget { if (result == null) { return; } - await onApply( + await onVoice( audioContentBase64: result.audioContentBase64, audioMimeType: result.audioMimeType, ); } + + Future _openTraits(BuildContext context) async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + return _TraitPickerSheet( + traits: traits, + selectedTrait: selectedTrait, + traitCounts: traitCounts, + onSelect: (trait) { + final controller = context.read(); + if (trait == selectedTrait) { + controller.clearTrait(); + } else { + controller.selectTrait(trait); + } + Navigator.of(sheetContext).pop(); + }, + ); + }, + ); + } } class _VoiceFilterAudio { @@ -767,46 +792,47 @@ class _MapAttribution extends StatelessWidget { } } -class _TraitBar extends StatelessWidget { - const _TraitBar({ +class _TraitPickerSheet extends StatelessWidget { + const _TraitPickerSheet({ required this.selectedTrait, required this.traits, required this.traitCounts, + required this.onSelect, }); final PlaceTrait? selectedTrait; final List traits; final Map traitCounts; + final ValueChanged onSelect; @override Widget build(BuildContext context) { final tokens = context.mapflowTokens; - final controller = context.read(); - return SizedBox( - height: 54, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - itemCount: traits.length, - separatorBuilder: (_, _) => const SizedBox(width: 6), - itemBuilder: (context, index) { - final item = traits[index]; - final selected = item == selectedTrait; - return ChoiceChip( - avatar: Icon(item.icon, size: 17), - label: Text('${item.label} ${traitCounts[item] ?? 0}'), - selected: selected, - onSelected: (_) => selected - ? controller.clearTrait() - : controller.selectTrait(item), - backgroundColor: tokens.mapPanel, - selectedColor: Theme.of(context).colorScheme.primaryContainer, - side: BorderSide.none, - shape: const StadiumBorder(), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), - ); - }, + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 18), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final item in traits) + ChoiceChip( + avatar: Icon(item.icon, size: 17), + label: Text('${item.label} ${traitCounts[item] ?? 0}'), + selected: item == selectedTrait, + onSelected: (_) => onSelect(item), + backgroundColor: tokens.mapPanel, + selectedColor: Theme.of(context).colorScheme.primaryContainer, + side: BorderSide.none, + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 9, + ), + ), + ], + ), ), ); } @@ -1760,11 +1786,7 @@ class _StepLayout extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - Expanded(child: body), - ], - ); + return Column(children: [Expanded(child: body)]); } } diff --git a/lib/features/mapflow/presentation/widgets/place_photo_card.dart b/lib/features/mapflow/presentation/widgets/place_photo_card.dart index 8f1a259..8f80b70 100644 --- a/lib/features/mapflow/presentation/widgets/place_photo_card.dart +++ b/lib/features/mapflow/presentation/widgets/place_photo_card.dart @@ -99,10 +99,7 @@ class PlacePhotoCard extends StatelessWidget { } class _PhotoTriptych extends StatelessWidget { - const _PhotoTriptych({ - required this.photoUrls, - required this.fallbackColor, - }); + const _PhotoTriptych({required this.photoUrls, required this.fallbackColor}); final List photoUrls; final Color fallbackColor;