Restructure map search and review actions
Build and deploy Flutter Web / build (push) Successful in 2m28s

This commit is contained in:
Ruslan Bakiev
2026-06-13 12:52:33 +07:00
parent e641837815
commit 9a87996436
@@ -74,6 +74,9 @@ class _MapContent extends StatelessWidget {
state.voiceFilterTags.isNotEmpty;
final hasRecommendationSurface =
hasVoiceRecommendation || state.selectedTrait != null;
final activeRecommendationLabel = hasVoiceRecommendation
? 'Голосовой поиск'
: state.selectedTrait?.label;
return Scaffold(
body: Stack(
@@ -127,34 +130,58 @@ class _MapContent extends StatelessWidget {
),
),
),
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: activeRecommendationLabel == null
? const SizedBox.shrink()
: _ActiveRecommendationChip(
label: activeRecommendationLabel,
onClear: hasVoiceRecommendation
? () => unawaited(placeCubit.clearVoiceFilter())
: placeCubit.clearTrait,
),
),
),
SafeArea(
child: Align(
alignment: Alignment.topRight,
child: _AddReviewAction(
onPressed: () => _openAddFlow(
context,
userCoordinate ?? selected?.coordinate,
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
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,
onAddReview: () => _openAddFlow(
context,
userCoordinate ?? selected?.coordinate,
),
onVoice: placeCubit.recommendPlacesByVoice,
onClear: hasVoiceRecommendation
? () => unawaited(placeCubit.clearVoiceFilter())
: placeCubit.clearTrait,
),
],
child: hasRecommendationSurface
? Padding(
padding: const EdgeInsets.only(bottom: 70),
child: _PlaceCarousel(
places: state.recommendations,
onSelect: (place) => placeCubit.selectPlace(place.id),
onFavoriteToggle: placeCubit.toggleFavorite,
),
)
: const SizedBox.shrink(),
),
),
Align(
alignment: Alignment.bottomLeft,
child: SafeArea(
top: false,
child: _SearchLauncherButton(
onPressed: () => _openSearchSheet(
context,
traits: availableTraits,
selectedTrait: state.selectedTrait,
traitCounts: traitCounts,
onVoice: placeCubit.recommendPlacesByVoice,
),
),
),
),
@@ -163,6 +190,40 @@ class _MapContent extends StatelessWidget {
);
}
Future<void> _openSearchSheet(
BuildContext context, {
required List<PlaceTrait> traits,
required PlaceTrait? selectedTrait,
required Map<PlaceTrait, int> traitCounts,
required Future<void> Function({
required String audioContentBase64,
required String audioMimeType,
})
onVoice,
}) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
return _SearchSheet(
traits: traits,
selectedTrait: selectedTrait,
traitCounts: traitCounts,
onVoice: onVoice,
onSelectTrait: (trait) {
final controller = context.read<PlaceCubit>();
if (trait == selectedTrait) {
controller.clearTrait();
} else {
controller.selectTrait(trait);
}
Navigator.of(sheetContext).pop();
},
);
},
);
}
void _openAddFlow(BuildContext context, LatLng? coordinate) {
context.push(
MapflowRoutes.addExperienceLocation(
@@ -299,27 +360,36 @@ class _UserAvatar extends StatelessWidget {
}
}
class _RecommendationControls extends StatelessWidget {
const _RecommendationControls({
required this.active,
required this.traits,
required this.selectedTrait,
required this.traitCounts,
required this.onAddReview,
required this.onVoice,
required this.onClear,
});
class _AddReviewAction extends StatelessWidget {
const _AddReviewAction({required this.onPressed});
final bool active;
final List<PlaceTrait> traits;
final PlaceTrait? selectedTrait;
final Map<PlaceTrait, int> traitCounts;
final VoidCallback onAddReview;
final Future<void> Function({
required String audioContentBase64,
required String audioMimeType,
})
onVoice;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(top: 62, right: 12),
child: FilledButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.rate_review_outlined, size: 18),
label: const Text('Отзыв'),
style: FilledButton.styleFrom(
backgroundColor: tokens.mapPanel,
foregroundColor: colorScheme.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
),
);
}
}
class _ActiveRecommendationChip extends StatelessWidget {
const _ActiveRecommendationChip({required this.label, required this.onClear});
final String label;
final VoidCallback onClear;
@override
@@ -327,85 +397,120 @@ class _RecommendationControls extends StatelessWidget {
final tokens = context.mapflowTokens;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
padding: const EdgeInsets.only(top: 62),
child: Material(
color: tokens.mapPanel,
borderRadius: BorderRadius.circular(tokens.panelRadius),
child: Padding(
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filled(
onPressed: onAddReview,
icon: const Icon(Icons.add_location_alt_outlined),
tooltip: 'Добавить отзыв',
),
const Icon(Icons.travel_explore_rounded, size: 18),
const SizedBox(width: 8),
Expanded(
child: FilledButton.icon(
onPressed: () => _openVoice(context),
icon: const Icon(Icons.mic_none_rounded, size: 18),
label: const Text('Голосом'),
Text(label, style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(width: 4),
IconButton(
onPressed: onClear,
icon: const Icon(Icons.close_rounded, size: 18),
tooltip: 'Сбросить',
style: IconButton.styleFrom(
fixedSize: const Size.square(32),
minimumSize: const Size.square(32),
padding: EdgeInsets.zero,
),
),
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: 'Сбросить',
),
],
],
),
),
),
);
}
}
Future<void> _openVoice(BuildContext context) async {
final result = await showDialog<_VoiceFilterAudio>(
context: context,
builder: (_) => const _VoiceFilterDialog(),
);
if (result == null) {
return;
}
await onVoice(
audioContentBase64: result.audioContentBase64,
audioMimeType: result.audioMimeType,
class _SearchLauncherButton extends StatelessWidget {
const _SearchLauncherButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 0, 12),
child: FloatingActionButton.extended(
heroTag: 'map-search',
onPressed: onPressed,
icon: const Icon(Icons.search_rounded),
label: const Text('Поиск'),
backgroundColor: tokens.mapPanel,
foregroundColor: colorScheme.onSurface,
),
);
}
}
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 _SearchSheet extends StatelessWidget {
const _SearchSheet({
required this.traits,
required this.selectedTrait,
required this.traitCounts,
required this.onVoice,
required this.onSelectTrait,
});
final List<PlaceTrait> traits;
final PlaceTrait? selectedTrait;
final Map<PlaceTrait, int> traitCounts;
final Future<void> Function({
required String audioContentBase64,
required String audioMimeType,
})
onVoice;
final ValueChanged<PlaceTrait> onSelectTrait;
@override
Widget build(BuildContext context) {
return SafeArea(
child: SizedBox(
height: 420,
child: DefaultTabController(
length: 2,
child: Column(
children: [
const TabBar(
tabs: [
Tab(icon: Icon(Icons.mic_none_rounded), text: 'Голосом'),
Tab(icon: Icon(Icons.tune_rounded), text: 'Фильтры'),
],
),
Expanded(
child: TabBarView(
children: [
_VoiceFilterRecorder(
onApply: (audio) async {
Navigator.of(context).pop();
await onVoice(
audioContentBase64: audio.audioContentBase64,
audioMimeType: audio.audioMimeType,
);
},
),
_TraitPickerSheet(
traits: traits,
selectedTrait: selectedTrait,
traitCounts: traitCounts,
onSelect: onSelectTrait,
),
],
),
),
],
),
),
),
);
}
}
@@ -420,14 +525,16 @@ class _VoiceFilterAudio {
final String audioMimeType;
}
class _VoiceFilterDialog extends StatefulWidget {
const _VoiceFilterDialog();
class _VoiceFilterRecorder extends StatefulWidget {
const _VoiceFilterRecorder({required this.onApply});
@override
State<_VoiceFilterDialog> createState() => _VoiceFilterDialogState();
State<_VoiceFilterRecorder> createState() => _VoiceFilterRecorderState();
final Future<void> Function(_VoiceFilterAudio audio) onApply;
}
class _VoiceFilterDialogState extends State<_VoiceFilterDialog> {
class _VoiceFilterRecorderState extends State<_VoiceFilterRecorder> {
final _waveController = WaveformRecorderController(
interval: const Duration(milliseconds: 60),
config: const RecordConfig(
@@ -480,7 +587,7 @@ class _VoiceFilterDialogState extends State<_VoiceFilterDialog> {
if (!mounted) {
return;
}
Navigator.of(context).pop(
await widget.onApply(
_VoiceFilterAudio(
audioContentBase64: base64Encode(bytes),
audioMimeType: file.mimeType ?? 'audio/wav',
@@ -490,46 +597,35 @@ class _VoiceFilterDialogState extends State<_VoiceFilterDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
contentPadding: const EdgeInsets.fromLTRB(18, 18, 18, 8),
content: SizedBox(
width: 260,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filled(
onPressed: _submitting ? null : _toggleRecording,
icon: Icon(_recording ? Icons.stop_rounded : Icons.mic_rounded),
iconSize: 34,
style: IconButton.styleFrom(
fixedSize: const Size.square(82),
minimumSize: const Size.square(82),
return Padding(
padding: const EdgeInsets.fromLTRB(18, 24, 18, 18),
child: Column(
children: [
Expanded(
child: Center(
child: IconButton.filled(
onPressed: _submitting ? null : _toggleRecording,
icon: Icon(_recording ? Icons.stop_rounded : Icons.mic_rounded),
iconSize: 44,
style: IconButton.styleFrom(
fixedSize: const Size.square(112),
minimumSize: const Size.square(112),
),
tooltip: _recording ? 'Стоп' : 'Записать',
),
tooltip: _recording ? 'Стоп' : 'Записать',
),
const SizedBox(height: 16),
Text(
_recording ? 'Слушаю' : 'Голосовой фильтр',
style: const TextStyle(fontWeight: FontWeight.w900),
),
],
),
),
FilledButton(
onPressed: _submitting || !_hasRecording ? null : _apply,
child: _submitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Применить'),
),
],
),
actions: [
TextButton(
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: _submitting || !_hasRecording ? null : _apply,
child: _submitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Применить'),
),
],
);
}
}