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:
@@ -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,16 +145,21 @@ class _MapContent extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (availableTraits.isNotEmpty)
|
if (hasRecommendationSurface)
|
||||||
_TraitBar(
|
_PlaceCarousel(
|
||||||
selectedTrait: state.selectedTrait,
|
places: state.recommendations,
|
||||||
traits: availableTraits,
|
onSelect: (place) => placeCubit.selectPlace(place.id),
|
||||||
traitCounts: traitCounts,
|
onFavoriteToggle: placeCubit.toggleFavorite,
|
||||||
),
|
),
|
||||||
_PlaceCarousel(
|
_RecommendationControls(
|
||||||
places: state.recommendations,
|
active: hasRecommendationSurface,
|
||||||
onSelect: (place) => placeCubit.selectPlace(place.id),
|
traits: availableTraits,
|
||||||
onFavoriteToggle: placeCubit.toggleFavorite,
|
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: Row(
|
||||||
child: SizedBox(
|
mainAxisSize: MainAxisSize.min,
|
||||||
height: 44,
|
children: [
|
||||||
child: Row(
|
Expanded(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: FilledButton.icon(
|
||||||
children: [
|
onPressed: () => _openVoice(context),
|
||||||
const SizedBox(width: 12),
|
icon: const Icon(Icons.mic_none_rounded, size: 18),
|
||||||
const Icon(Icons.mic_none_rounded, size: 18),
|
label: const Text('Голосом'),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
Flexible(
|
),
|
||||||
child: Text(
|
const SizedBox(width: 8),
|
||||||
label,
|
Expanded(
|
||||||
maxLines: 1,
|
child: OutlinedButton.icon(
|
||||||
overflow: TextOverflow.ellipsis,
|
onPressed: traits.isEmpty ? null : () => _openTraits(context),
|
||||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
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>(
|
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;
|
avatar: Icon(item.icon, size: 17),
|
||||||
return ChoiceChip(
|
label: Text('${item.label} ${traitCounts[item] ?? 0}'),
|
||||||
avatar: Icon(item.icon, size: 17),
|
selected: item == selectedTrait,
|
||||||
label: Text('${item.label} ${traitCounts[item] ?? 0}'),
|
onSelected: (_) => onSelect(item),
|
||||||
selected: selected,
|
backgroundColor: tokens.mapPanel,
|
||||||
onSelected: (_) => selected
|
selectedColor: Theme.of(context).colorScheme.primaryContainer,
|
||||||
? controller.clearTrait()
|
side: BorderSide.none,
|
||||||
: controller.selectTrait(item),
|
shape: const StadiumBorder(),
|
||||||
backgroundColor: tokens.mapPanel,
|
padding: const EdgeInsets.symmetric(
|
||||||
selectedColor: Theme.of(context).colorScheme.primaryContainer,
|
horizontal: 10,
|
||||||
side: BorderSide.none,
|
vertical: 9,
|
||||||
shape: const StadiumBorder(),
|
),
|
||||||
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user