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(
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) {
@@ -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,17 +145,22 @@ 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,
),
_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<PlaceTrait> traits;
final PlaceTrait? selectedTrait;
final Map<PlaceTrait, int> traitCounts;
final Future<void> Function({
required String audioContentBase64,
required String audioMimeType,
})
onApply;
final Future<void> 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: Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 12),
const Icon(Icons.mic_none_rounded, size: 18),
Expanded(
child: FilledButton.icon(
onPressed: () => _openVoice(context),
icon: const Icon(Icons.mic_none_rounded, size: 18),
label: const Text('Голосом'),
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w800),
Expanded(
child: OutlinedButton.icon(
onPressed: traits.isEmpty ? null : () => _openTraits(context),
icon: const Icon(Icons.tune_rounded, size: 18),
label: const Text('Теги'),
),
),
if (onClear != null) ...[
const SizedBox(width: 2),
if (active) ...[
const SizedBox(width: 4),
IconButton(
onPressed: () => onClear!(),
icon: const Icon(Icons.close, size: 18),
onPressed: onClear,
icon: const Icon(Icons.close_rounded),
tooltip: 'Сбросить',
),
] else
const SizedBox(width: 12),
],
),
],
),
),
),
);
}
Future<void> _openSearch(BuildContext context) async {
Future<void> _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<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 {
@@ -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<PlaceTrait> traits;
final Map<PlaceTrait, int> traitCounts;
final ValueChanged<PlaceTrait> onSelect;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final controller = context.read<PlaceCubit>();
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(
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: selected,
onSelected: (_) => selected
? controller.clearTrait()
: controller.selectTrait(item),
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),
);
},
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)]);
}
}
@@ -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<String> photoUrls;
final Color fallbackColor;