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 {
), ),
), ),
), ),
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( Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: SafeArea( child: SafeArea(
top: false, top: false,
child: Column( child: hasRecommendationSurface
mainAxisSize: MainAxisSize.min, ? Padding(
children: [ padding: const EdgeInsets.only(bottom: 70),
if (hasRecommendationSurface) child: _PlaceCarousel(
_PlaceCarousel( places: state.recommendations,
places: state.recommendations, onSelect: (place) => placeCubit.selectPlace(place.id),
onSelect: (place) => placeCubit.selectPlace(place.id), onFavoriteToggle: placeCubit.toggleFavorite,
onFavoriteToggle: placeCubit.toggleFavorite, ),
), )
_RecommendationControls( : const SizedBox.shrink(),
active: hasRecommendationSurface, ),
traits: availableTraits, ),
selectedTrait: state.selectedTrait, Align(
traitCounts: traitCounts, alignment: Alignment.bottomLeft,
onAddReview: () => _openAddFlow( child: SafeArea(
context, top: false,
userCoordinate ?? selected?.coordinate, child: _SearchLauncherButton(
), onPressed: () => _openSearchSheet(
onVoice: placeCubit.recommendPlacesByVoice, context,
onClear: hasVoiceRecommendation traits: availableTraits,
? () => unawaited(placeCubit.clearVoiceFilter()) selectedTrait: state.selectedTrait,
: placeCubit.clearTrait, 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( const SizedBox(width: 4),
onPressed: () => _openVoice(context), IconButton(
icon: const Icon(Icons.mic_none_rounded, size: 18), onPressed: onClear,
label: const Text('Голосом'), 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 { class _SearchLauncherButton extends StatelessWidget {
final result = await showDialog<_VoiceFilterAudio>( const _SearchLauncherButton({required this.onPressed});
context: context,
builder: (_) => const _VoiceFilterDialog(), final VoidCallback onPressed;
);
if (result == null) { @override
return; Widget build(BuildContext context) {
} final tokens = context.mapflowTokens;
await onVoice( final colorScheme = Theme.of(context).colorScheme;
audioContentBase64: result.audioContentBase64,
audioMimeType: result.audioMimeType, 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 { class _SearchSheet extends StatelessWidget {
await showModalBottomSheet<void>( const _SearchSheet({
context: context, required this.traits,
showDragHandle: true, required this.selectedTrait,
builder: (sheetContext) { required this.traitCounts,
return _TraitPickerSheet( required this.onVoice,
traits: traits, required this.onSelectTrait,
selectedTrait: selectedTrait, });
traitCounts: traitCounts,
onSelect: (trait) { final List<PlaceTrait> traits;
final controller = context.read<PlaceCubit>(); final PlaceTrait? selectedTrait;
if (trait == selectedTrait) { final Map<PlaceTrait, int> traitCounts;
controller.clearTrait(); final Future<void> Function({
} else { required String audioContentBase64,
controller.selectTrait(trait); required String audioMimeType,
} })
Navigator.of(sheetContext).pop(); 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; 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,46 +597,35 @@ 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( child: Column(
width: 260, children: [
child: Column( Expanded(
mainAxisSize: MainAxisSize.min, child: Center(
children: [ child: IconButton.filled(
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: 44,
iconSize: 34, style: IconButton.styleFrom(
style: IconButton.styleFrom( fixedSize: const Size.square(112),
fixedSize: const Size.square(82), minimumSize: const Size.square(112),
minimumSize: const Size.square(82), ),
tooltip: _recording ? 'Стоп' : 'Записать',
), ),
tooltip: _recording ? 'Стоп' : 'Записать',
), ),
const SizedBox(height: 16), ),
Text( FilledButton(
_recording ? 'Слушаю' : 'Голосовой фильтр', onPressed: _submitting || !_hasRecording ? null : _apply,
style: const TextStyle(fontWeight: FontWeight.w900), 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('Применить'),
),
],
); );
} }
} }