Replace chat search with voice filter recommendations
Build and deploy Flutter Web / build (push) Successful in 3m11s

This commit is contained in:
Ruslan Bakiev
2026-06-12 22:43:05 +07:00
parent 318126d83a
commit ba85f31cef
22 changed files with 1360 additions and 884 deletions
@@ -125,12 +125,12 @@ class _MapContent extends StatelessWidget {
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: _MapSearchButton(
query: state.searchQuery,
onSearch: placeCubit.searchPlaces,
onClear: state.searchQuery.isEmpty
child: _VoiceFilterButton(
transcript: state.voiceFilterTranscript,
onApply: placeCubit.recommendPlacesByVoice,
onClear: state.voiceFilterTranscript.isEmpty
? null
: placeCubit.clearSearch,
: placeCubit.clearVoiceFilter,
),
),
),
@@ -334,21 +334,25 @@ class _AddReviewButton extends StatelessWidget {
}
}
class _MapSearchButton extends StatelessWidget {
const _MapSearchButton({
required this.query,
required this.onSearch,
class _VoiceFilterButton extends StatelessWidget {
const _VoiceFilterButton({
required this.transcript,
required this.onApply,
required this.onClear,
});
final String query;
final Future<void> Function(String) onSearch;
final String transcript;
final Future<void> Function({
required String audioContentBase64,
required String audioMimeType,
})
onApply;
final Future<void> Function()? onClear;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final label = query.isEmpty ? 'Поиск' : query;
final label = transcript.isEmpty ? 'Голосом' : transcript;
return Padding(
padding: const EdgeInsets.only(top: 8, left: 70, right: 70),
@@ -364,7 +368,7 @@ class _MapSearchButton extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 12),
const Icon(Icons.auto_awesome_outlined, size: 18),
const Icon(Icons.mic_none_rounded, size: 18),
const SizedBox(width: 8),
Flexible(
child: Text(
@@ -392,40 +396,141 @@ class _MapSearchButton extends StatelessWidget {
}
Future<void> _openSearch(BuildContext context) async {
final controller = TextEditingController(text: query);
final result = await showDialog<String>(
final result = await showDialog<_VoiceFilterAudio>(
context: context,
builder: (context) {
return AlertDialog(
contentPadding: const EdgeInsets.fromLTRB(18, 18, 18, 8),
content: TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.search,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.auto_awesome_outlined),
hintText: 'Что ищем?',
),
onSubmitted: (value) => Navigator.of(context).pop(value),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text),
child: const Text('Найти'),
),
],
);
},
builder: (_) => const _VoiceFilterDialog(),
);
controller.dispose();
if (result == null) {
return;
}
await onSearch(result);
await onApply(
audioContentBase64: result.audioContentBase64,
audioMimeType: result.audioMimeType,
);
}
}
class _VoiceFilterAudio {
const _VoiceFilterAudio({
required this.audioContentBase64,
required this.audioMimeType,
});
final String audioContentBase64;
final String audioMimeType;
}
class _VoiceFilterDialog extends StatefulWidget {
const _VoiceFilterDialog();
@override
State<_VoiceFilterDialog> createState() => _VoiceFilterDialogState();
}
class _VoiceFilterDialogState extends State<_VoiceFilterDialog> {
final _waveController = WaveformRecorderController(
interval: const Duration(milliseconds: 60),
config: const RecordConfig(
numChannels: 1,
sampleRate: 44100,
autoGain: true,
echoCancel: true,
noiseSuppress: true,
),
);
var _recording = false;
var _submitting = false;
var _hasRecording = false;
@override
void dispose() {
_waveController.dispose();
super.dispose();
}
Future<void> _toggleRecording() async {
if (_recording) {
await _waveController.stopRecording();
setState(() {
_recording = false;
_hasRecording = true;
});
return;
}
await _waveController.startRecording();
setState(() => _recording = true);
}
Future<void> _apply() async {
if (_recording) {
await _waveController.stopRecording();
setState(() {
_recording = false;
_hasRecording = true;
});
}
final file = _waveController.file;
if (file == null) {
throw StateError('Voice filter recording file is required.');
}
setState(() => _submitting = true);
final bytes = await file.readAsBytes();
if (!mounted) {
return;
}
Navigator.of(context).pop(
_VoiceFilterAudio(
audioContentBase64: base64Encode(bytes),
audioMimeType: file.mimeType ?? 'audio/wav',
),
);
}
@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),
),
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(
onPressed: _submitting || !_hasRecording ? null : _apply,
child: _submitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Применить'),
),
],
);
}
}