diff --git a/lib/features/mapflow/presentation/mapflow_shell.dart b/lib/features/mapflow/presentation/mapflow_shell.dart index 49f1ec6..4a52293 100644 --- a/lib/features/mapflow/presentation/mapflow_shell.dart +++ b/lib/features/mapflow/presentation/mapflow_shell.dart @@ -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 _openSearchSheet( + BuildContext context, { + required List traits, + required PlaceTrait? selectedTrait, + required Map traitCounts, + required Future Function({ + required String audioContentBase64, + required String audioMimeType, + }) + onVoice, + }) async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + return _SearchSheet( + traits: traits, + selectedTrait: selectedTrait, + traitCounts: traitCounts, + onVoice: onVoice, + onSelectTrait: (trait) { + final controller = context.read(); + 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 traits; - final PlaceTrait? selectedTrait; - final Map traitCounts; - final VoidCallback onAddReview; - final Future 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 _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 _openTraits(BuildContext context) async { - await showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) { - return _TraitPickerSheet( - traits: traits, - selectedTrait: selectedTrait, - traitCounts: traitCounts, - onSelect: (trait) { - final controller = context.read(); - 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 traits; + final PlaceTrait? selectedTrait; + final Map traitCounts; + final Future Function({ + required String audioContentBase64, + required String audioMimeType, + }) + onVoice; + final ValueChanged 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 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('Применить'), - ), - ], ); } }