Move recommendations behind search controls
Build and deploy Flutter Web / build (push) Successful in 2m40s
Build and deploy Flutter Web / build (push) Successful in 2m40s
This commit is contained in:
@@ -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<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: 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<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(
|
||||
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)]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user