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;
|
||||
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('Применить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user