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; state.voiceFilterTags.isNotEmpty;
final hasRecommendationSurface = final hasRecommendationSurface =
hasVoiceRecommendation || state.selectedTrait != null; hasVoiceRecommendation || state.selectedTrait != null;
final activeRecommendationLabel = hasVoiceRecommendation
? 'Голосовой поиск'
: state.selectedTrait?.label;
return Scaffold( return Scaffold(
body: Stack( body: Stack(
@@ -127,34 +130,58 @@ class _MapContent extends StatelessWidget {
), ),
), ),
), ),
Align( SafeArea(
alignment: Alignment.bottomCenter, child: Align(
child: SafeArea( alignment: Alignment.topCenter,
top: false, child: activeRecommendationLabel == null
child: Column( ? const SizedBox.shrink()
mainAxisSize: MainAxisSize.min, : _ActiveRecommendationChip(
children: [ label: activeRecommendationLabel,
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 onClear: hasVoiceRecommendation
? () => unawaited(placeCubit.clearVoiceFilter()) ? () => unawaited(placeCubit.clearVoiceFilter())
: placeCubit.clearTrait, : 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: 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) { void _openAddFlow(BuildContext context, LatLng? coordinate) {
context.push( context.push(
MapflowRoutes.addExperienceLocation( MapflowRoutes.addExperienceLocation(
@@ -299,27 +360,36 @@ class _UserAvatar extends StatelessWidget {
} }
} }
class _RecommendationControls extends StatelessWidget { class _AddReviewAction extends StatelessWidget {
const _RecommendationControls({ const _AddReviewAction({required this.onPressed});
required this.active,
required this.traits,
required this.selectedTrait,
required this.traitCounts,
required this.onAddReview,
required this.onVoice,
required this.onClear,
});
final bool active; final VoidCallback onPressed;
final List<PlaceTrait> traits;
final PlaceTrait? selectedTrait; @override
final Map<PlaceTrait, int> traitCounts; Widget build(BuildContext context) {
final VoidCallback onAddReview; final tokens = context.mapflowTokens;
final Future<void> Function({ final colorScheme = Theme.of(context).colorScheme;
required String audioContentBase64,
required String audioMimeType, return Padding(
}) padding: const EdgeInsets.only(top: 62, right: 12),
onVoice; 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; final VoidCallback onClear;
@override @override
@@ -327,85 +397,120 @@ class _RecommendationControls extends StatelessWidget {
final tokens = context.mapflowTokens; final tokens = context.mapflowTokens;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), padding: const EdgeInsets.only(top: 62),
child: Material( child: Material(
color: tokens.mapPanel, color: tokens.mapPanel,
borderRadius: BorderRadius.circular(tokens.panelRadius), borderRadius: BorderRadius.circular(tokens.panelRadius),
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton.filled( const Icon(Icons.travel_explore_rounded, size: 18),
onPressed: onAddReview,
icon: const Icon(Icons.add_location_alt_outlined),
tooltip: 'Добавить отзыв',
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Text(label, style: const TextStyle(fontWeight: FontWeight.w800)),
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), const SizedBox(width: 4),
IconButton( IconButton(
onPressed: onClear, onPressed: onClear,
icon: const Icon(Icons.close_rounded), icon: const Icon(Icons.close_rounded, size: 18),
tooltip: 'Сбросить', tooltip: 'Сбросить',
style: IconButton.styleFrom(
fixedSize: const Size.square(32),
minimumSize: const Size.square(32),
padding: EdgeInsets.zero,
),
), ),
],
], ],
), ),
), ),
), ),
); );
} }
}
Future<void> _openVoice(BuildContext context) async { class _SearchLauncherButton extends StatelessWidget {
final result = await showDialog<_VoiceFilterAudio>( const _SearchLauncherButton({required this.onPressed});
context: context,
builder: (_) => const _VoiceFilterDialog(), 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,
),
); );
if (result == null) {
return;
} }
}
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( await onVoice(
audioContentBase64: result.audioContentBase64, audioContentBase64: audio.audioContentBase64,
audioMimeType: result.audioMimeType, audioMimeType: audio.audioMimeType,
); );
} },
),
Future<void> _openTraits(BuildContext context) async { _TraitPickerSheet(
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
return _TraitPickerSheet(
traits: traits, traits: traits,
selectedTrait: selectedTrait, selectedTrait: selectedTrait,
traitCounts: traitCounts, traitCounts: traitCounts,
onSelect: (trait) { onSelect: onSelectTrait,
final controller = context.read<PlaceCubit>(); ),
if (trait == selectedTrait) { ],
controller.clearTrait(); ),
} else { ),
controller.selectTrait(trait); ],
} ),
Navigator.of(sheetContext).pop(); ),
}, ),
);
},
); );
} }
} }
@@ -420,14 +525,16 @@ class _VoiceFilterAudio {
final String audioMimeType; final String audioMimeType;
} }
class _VoiceFilterDialog extends StatefulWidget { class _VoiceFilterRecorder extends StatefulWidget {
const _VoiceFilterDialog(); const _VoiceFilterRecorder({required this.onApply});
@override @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( final _waveController = WaveformRecorderController(
interval: const Duration(milliseconds: 60), interval: const Duration(milliseconds: 60),
config: const RecordConfig( config: const RecordConfig(
@@ -480,7 +587,7 @@ class _VoiceFilterDialogState extends State<_VoiceFilterDialog> {
if (!mounted) { if (!mounted) {
return; return;
} }
Navigator.of(context).pop( await widget.onApply(
_VoiceFilterAudio( _VoiceFilterAudio(
audioContentBase64: base64Encode(bytes), audioContentBase64: base64Encode(bytes),
audioMimeType: file.mimeType ?? 'audio/wav', audioMimeType: file.mimeType ?? 'audio/wav',
@@ -490,35 +597,23 @@ class _VoiceFilterDialogState extends State<_VoiceFilterDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return Padding(
contentPadding: const EdgeInsets.fromLTRB(18, 18, 18, 8), padding: const EdgeInsets.fromLTRB(18, 24, 18, 18),
content: SizedBox(
width: 260,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton.filled( Expanded(
child: Center(
child: IconButton.filled(
onPressed: _submitting ? null : _toggleRecording, onPressed: _submitting ? null : _toggleRecording,
icon: Icon(_recording ? Icons.stop_rounded : Icons.mic_rounded), icon: Icon(_recording ? Icons.stop_rounded : Icons.mic_rounded),
iconSize: 34, iconSize: 44,
style: IconButton.styleFrom( style: IconButton.styleFrom(
fixedSize: const Size.square(82), fixedSize: const Size.square(112),
minimumSize: const Size.square(82), minimumSize: const Size.square(112),
), ),
tooltip: _recording ? 'Стоп' : 'Записать', tooltip: _recording ? 'Стоп' : 'Записать',
), ),
const SizedBox(height: 16),
Text(
_recording ? 'Слушаю' : 'Голосовой фильтр',
style: const TextStyle(fontWeight: FontWeight.w900),
), ),
],
),
),
actions: [
TextButton(
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
child: const Text('Отмена'),
), ),
FilledButton( FilledButton(
onPressed: _submitting || !_hasRecording ? null : _apply, onPressed: _submitting || !_hasRecording ? null : _apply,
@@ -530,6 +625,7 @@ class _VoiceFilterDialogState extends State<_VoiceFilterDialog> {
: const Text('Применить'), : const Text('Применить'),
), ),
], ],
),
); );
} }
} }