Restructure map search and review actions
Build and deploy Flutter Web / build (push) Successful in 2m28s
Build and deploy Flutter Web / build (push) Successful in 2m28s
This commit is contained in:
@@ -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('Применить'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user