Move recommendations behind search controls
Build and deploy Flutter Web / build (push) Successful in 2m40s

This commit is contained in:
Ruslan Bakiev
2026-06-12 23:13:24 +07:00
parent 0f5061ce96
commit b9d8192d01
3 changed files with 116 additions and 100 deletions
+1 -4
View File
@@ -19,10 +19,7 @@ GoRouter createAppRouter() {
return GoRouter( return GoRouter(
errorBuilder: (context, state) => const MapflowShell(), errorBuilder: (context, state) => const MapflowShell(),
routes: [ routes: [
GoRoute( GoRoute(path: '/', builder: (context, state) => const MapflowShell()),
path: '/',
builder: (context, state) => const MapflowShell(),
),
GoRoute( GoRoute(
path: '/experience/new', path: '/experience/new',
pageBuilder: (context, state) { pageBuilder: (context, state) {
@@ -69,6 +69,11 @@ class _MapContent extends StatelessWidget {
for (final trait in PlaceTrait.values) for (final trait in PlaceTrait.values)
if ((traitCounts[trait] ?? 0) > 0) trait, if ((traitCounts[trait] ?? 0) > 0) trait,
]; ];
final hasVoiceRecommendation =
state.voiceFilterTranscript.isNotEmpty ||
state.voiceFilterTags.isNotEmpty;
final hasRecommendationSurface =
hasVoiceRecommendation || state.selectedTrait != null;
return Scaffold( return Scaffold(
body: Stack( 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( SafeArea(
child: Align( child: Align(
alignment: Alignment.topRight, alignment: Alignment.topRight,
@@ -152,17 +145,22 @@ class _MapContent extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (availableTraits.isNotEmpty) if (hasRecommendationSurface)
_TraitBar(
selectedTrait: state.selectedTrait,
traits: availableTraits,
traitCounts: traitCounts,
),
_PlaceCarousel( _PlaceCarousel(
places: state.recommendations, places: state.recommendations,
onSelect: (place) => placeCubit.selectPlace(place.id), onSelect: (place) => placeCubit.selectPlace(place.id),
onFavoriteToggle: placeCubit.toggleFavorite, 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 { class _RecommendationControls extends StatelessWidget {
const _VoiceFilterButton({ const _RecommendationControls({
required this.transcript, required this.active,
required this.onApply, required this.traits,
required this.selectedTrait,
required this.traitCounts,
required this.onVoice,
required this.onClear, required this.onClear,
}); });
final String transcript; final bool active;
final List<PlaceTrait> traits;
final PlaceTrait? selectedTrait;
final Map<PlaceTrait, int> traitCounts;
final Future<void> Function({ final Future<void> Function({
required String audioContentBase64, required String audioContentBase64,
required String audioMimeType, required String audioMimeType,
}) })
onApply; onVoice;
final Future<void> Function()? onClear; final VoidCallback onClear;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = context.mapflowTokens; final tokens = context.mapflowTokens;
final label = transcript.isEmpty ? 'Голосом' : transcript;
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8, left: 70, right: 70), padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Material( child: Material(
color: tokens.mapPanel, color: tokens.mapPanel,
borderRadius: BorderRadius.circular(tokens.panelRadius), borderRadius: BorderRadius.circular(tokens.panelRadius),
child: InkWell( child: Padding(
onTap: () => _openSearch(context), padding: const EdgeInsets.all(8),
borderRadius: BorderRadius.circular(tokens.panelRadius),
child: SizedBox(
height: 44,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(width: 12), Expanded(
const Icon(Icons.mic_none_rounded, size: 18), child: FilledButton.icon(
onPressed: () => _openVoice(context),
icon: const Icon(Icons.mic_none_rounded, size: 18),
label: const Text('Голосом'),
),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Flexible( Expanded(
child: Text( child: OutlinedButton.icon(
label, onPressed: traits.isEmpty ? null : () => _openTraits(context),
maxLines: 1, icon: const Icon(Icons.tune_rounded, size: 18),
overflow: TextOverflow.ellipsis, label: const Text('Теги'),
style: const TextStyle(fontWeight: FontWeight.w800),
), ),
), ),
if (onClear != null) ...[ if (active) ...[
const SizedBox(width: 2), const SizedBox(width: 4),
IconButton( IconButton(
onPressed: () => onClear!(), onPressed: onClear,
icon: const Icon(Icons.close, size: 18), icon: const Icon(Icons.close_rounded),
tooltip: 'Сбросить', tooltip: 'Сбросить',
), ),
] else
const SizedBox(width: 12),
], ],
), ],
), ),
), ),
), ),
); );
} }
Future<void> _openSearch(BuildContext context) async { Future<void> _openVoice(BuildContext context) async {
final result = await showDialog<_VoiceFilterAudio>( final result = await showDialog<_VoiceFilterAudio>(
context: context, context: context,
builder: (_) => const _VoiceFilterDialog(), builder: (_) => const _VoiceFilterDialog(),
@@ -406,11 +408,34 @@ class _VoiceFilterButton extends StatelessWidget {
if (result == null) { if (result == null) {
return; return;
} }
await onApply( await onVoice(
audioContentBase64: result.audioContentBase64, audioContentBase64: result.audioContentBase64,
audioMimeType: result.audioMimeType, audioMimeType: result.audioMimeType,
); );
} }
Future<void> _openTraits(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
return _TraitPickerSheet(
traits: traits,
selectedTrait: selectedTrait,
traitCounts: traitCounts,
onSelect: (trait) {
final controller = context.read<PlaceCubit>();
if (trait == selectedTrait) {
controller.clearTrait();
} else {
controller.selectTrait(trait);
}
Navigator.of(sheetContext).pop();
},
);
},
);
}
} }
class _VoiceFilterAudio { class _VoiceFilterAudio {
@@ -767,46 +792,47 @@ class _MapAttribution extends StatelessWidget {
} }
} }
class _TraitBar extends StatelessWidget { class _TraitPickerSheet extends StatelessWidget {
const _TraitBar({ const _TraitPickerSheet({
required this.selectedTrait, required this.selectedTrait,
required this.traits, required this.traits,
required this.traitCounts, required this.traitCounts,
required this.onSelect,
}); });
final PlaceTrait? selectedTrait; final PlaceTrait? selectedTrait;
final List<PlaceTrait> traits; final List<PlaceTrait> traits;
final Map<PlaceTrait, int> traitCounts; final Map<PlaceTrait, int> traitCounts;
final ValueChanged<PlaceTrait> onSelect;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = context.mapflowTokens; final tokens = context.mapflowTokens;
final controller = context.read<PlaceCubit>();
return SizedBox( return SafeArea(
height: 54, child: Padding(
child: ListView.separated( padding: const EdgeInsets.fromLTRB(16, 0, 16, 18),
scrollDirection: Axis.horizontal, child: Wrap(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), spacing: 8,
itemCount: traits.length, runSpacing: 8,
separatorBuilder: (_, _) => const SizedBox(width: 6), children: [
itemBuilder: (context, index) { for (final item in traits)
final item = traits[index]; ChoiceChip(
final selected = item == selectedTrait;
return ChoiceChip(
avatar: Icon(item.icon, size: 17), avatar: Icon(item.icon, size: 17),
label: Text('${item.label} ${traitCounts[item] ?? 0}'), label: Text('${item.label} ${traitCounts[item] ?? 0}'),
selected: selected, selected: item == selectedTrait,
onSelected: (_) => selected onSelected: (_) => onSelect(item),
? controller.clearTrait()
: controller.selectTrait(item),
backgroundColor: tokens.mapPanel, backgroundColor: tokens.mapPanel,
selectedColor: Theme.of(context).colorScheme.primaryContainer, selectedColor: Theme.of(context).colorScheme.primaryContainer,
side: BorderSide.none, side: BorderSide.none,
shape: const StadiumBorder(), shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), padding: const EdgeInsets.symmetric(
); horizontal: 10,
}, vertical: 9,
),
),
],
),
), ),
); );
} }
@@ -1760,11 +1786,7 @@ class _StepLayout extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(children: [Expanded(child: body)]);
children: [
Expanded(child: body),
],
);
} }
} }
@@ -99,10 +99,7 @@ class PlacePhotoCard extends StatelessWidget {
} }
class _PhotoTriptych extends StatelessWidget { class _PhotoTriptych extends StatelessWidget {
const _PhotoTriptych({ const _PhotoTriptych({required this.photoUrls, required this.fallbackColor});
required this.photoUrls,
required this.fallbackColor,
});
final List<String> photoUrls; final List<String> photoUrls;
final Color fallbackColor; final Color fallbackColor;