From b819b51c1fe05b2f2d888feb07056fe5f0fcb480 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Sat, 9 May 2026 14:08:27 +0700 Subject: [PATCH] Add live microphone waveform --- android/app/src/main/AndroidManifest.xml | 1 + ios/Runner/Info.plist | 2 + lib/screens/mapflow_shell.dart | 247 ++++++++++++++---- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Runner/DebugProfile.entitlements | 2 + macos/Runner/Info.plist | 2 + macos/Runner/Release.entitlements | 2 + pubspec.lock | 64 +++++ pubspec.yaml | 1 + 9 files changed, 268 insertions(+), 55 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a8ce98a..ea345f7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + NSLocationWhenInUseUsageDescription MapFlow uses your location to show places around you. + NSMicrophoneUsageDescription + MapFlow uses your microphone to record your voice review. UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index 4106245..4dce632 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -1,10 +1,12 @@ import 'dart:async'; import 'dart:math' as math; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:latlong2/latlong.dart'; +import 'package:latlong2/latlong.dart' hide Path; +import 'package:record/record.dart'; import '../api/mapflow_api.dart'; import '../auth/telegram_login_button.dart'; @@ -719,11 +721,16 @@ class AddExperienceFlow extends ConsumerStatefulWidget { class _AddExperienceFlowState extends ConsumerState { static const _minimumVoiceSeconds = 30; + final _recorder = AudioRecorder(); + final _waveSamples = List.filled(64, 0.04); + Timer? _timer; + StreamSubscription? _audioStreamSub; var _step = 0; var _seconds = 0; var _recording = false; var _submitting = false; + var _micAllowed = true; PlaceRecommendation? _selectedPlace; @override @@ -734,24 +741,89 @@ class _AddExperienceFlowState extends ConsumerState { @override void dispose() { _timer?.cancel(); + _audioStreamSub?.cancel(); + _recorder.dispose(); super.dispose(); } - void _toggleRecording() { - setState(() => _recording = !_recording); + Future _toggleRecording() async { if (_recording) { - _timer = Timer.periodic(const Duration(seconds: 1), (_) { - if (!mounted) { - return; - } - setState(() => _seconds += 1); - ref - .read(placeControllerProvider.notifier) - .setReviewDuration(Duration(seconds: _seconds)); - }); - } else { - _timer?.cancel(); + await _stopRecording(); + return; } + + await _startRecording(); + } + + Future _startRecording() async { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + setState(() => _micAllowed = false); + return; + } + + final stream = await _recorder.startStream( + const RecordConfig( + encoder: AudioEncoder.pcm16bits, + sampleRate: 44100, + numChannels: 1, + echoCancel: true, + noiseSuppress: true, + autoGain: true, + ), + ); + _audioStreamSub = stream.listen(_handleAudioChunk); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) { + return; + } + setState(() => _seconds += 1); + ref + .read(placeControllerProvider.notifier) + .setReviewDuration(Duration(seconds: _seconds)); + }); + + setState(() { + _micAllowed = true; + _recording = true; + }); + } + + Future _stopRecording() async { + _timer?.cancel(); + await _audioStreamSub?.cancel(); + _audioStreamSub = null; + await _recorder.stop(); + if (!mounted) { + return; + } + setState(() => _recording = false); + } + + void _handleAudioChunk(Uint8List chunk) { + if (chunk.length < 2) { + return; + } + + final bytes = ByteData.sublistView(chunk); + var sum = 0.0; + var count = 0; + for (var index = 0; index + 1 < chunk.length; index += 2) { + final sample = bytes.getInt16(index, Endian.little) / 32768.0; + sum += sample * sample; + count += 1; + } + + final rms = count == 0 ? 0.0 : math.sqrt(sum / count); + final level = (rms * 7.5).clamp(0.03, 1.0).toDouble(); + if (!mounted) { + return; + } + setState(() { + _waveSamples + ..removeAt(0) + ..add(level); + }); } String get _time => @@ -783,6 +855,8 @@ class _AddExperienceFlowState extends ConsumerState { time: _time, isRecording: _recording, isSubmitting: _submitting, + micAllowed: _micAllowed, + samples: _waveSamples, canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds, onToggleRecording: _toggleRecording, onNext: () async { @@ -790,6 +864,9 @@ class _AddExperienceFlowState extends ConsumerState { if (selectedPlace == null) { return; } + if (_recording) { + await _stopRecording(); + } setState(() => _submitting = true); controller.setReviewPlace(selectedPlace.name); await controller.publishReview(coordinate: selectedPlace.coordinate); @@ -935,6 +1012,8 @@ class _VoiceStep extends StatelessWidget { required this.time, required this.isRecording, required this.isSubmitting, + required this.micAllowed, + required this.samples, required this.canContinue, required this.onToggleRecording, required this.onNext, @@ -947,8 +1026,10 @@ class _VoiceStep extends StatelessWidget { final String time; final bool isRecording; final bool isSubmitting; + final bool micAllowed; + final List samples; final bool canContinue; - final VoidCallback onToggleRecording; + final Future Function() onToggleRecording; final VoidCallback onNext; @override @@ -965,26 +1046,17 @@ class _VoiceStep extends StatelessWidget { ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), ), const SizedBox(height: 8), - Text( - hasTelegramAuth - ? 'Минимум $minimumSeconds секунд' - : 'Открой через Telegram', - textAlign: TextAlign.center, - ), + Text(hasTelegramAuth ? 'Минимум $minimumSeconds секунд' : ''), const SizedBox(height: 26), - if (isRecording || seconds > 0) ...[ - _VoiceWave(seconds: seconds, active: isRecording), - const SizedBox(height: 20), - ] else ...[ - const SizedBox(height: 70), - ], + _VoiceWave(samples: samples, active: isRecording), + const SizedBox(height: 24), SizedBox( width: 132, height: 132, child: FilledButton( onPressed: isSubmitting || !hasTelegramAuth ? null - : onToggleRecording, + : () => onToggleRecording(), style: FilledButton.styleFrom(shape: const CircleBorder()), child: Icon(isRecording ? Icons.stop : Icons.mic, size: 54), ), @@ -1000,6 +1072,10 @@ class _VoiceStep extends StatelessWidget { LinearProgressIndicator( value: (seconds / minimumSeconds).clamp(0.0, 1.0), ), + if (!micAllowed) ...[ + const SizedBox(height: 12), + const Icon(Icons.mic_off_outlined, size: 22), + ], const Spacer(), ], ), @@ -1012,45 +1088,106 @@ class _VoiceStep extends StatelessWidget { } class _VoiceWave extends StatelessWidget { - const _VoiceWave({required this.seconds, required this.active}); + const _VoiceWave({required this.samples, required this.active}); - final int seconds; + final List samples; final bool active; @override Widget build(BuildContext context) { - final color = Theme.of(context).colorScheme.primary; return SizedBox( - height: 50, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - for (var index = 0; index < 23; index++) - AnimatedContainer( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - width: 4, - height: _barHeight(index), - margin: const EdgeInsets.symmetric(horizontal: 3), - decoration: BoxDecoration( - color: color.withValues(alpha: active ? 0.88 : 0.42), - borderRadius: BorderRadius.circular(8), - ), - ), - ], + height: 112, + width: double.infinity, + child: CustomPaint( + painter: _VoiceWavePainter( + samples: samples, + active: active, + color: Theme.of(context).colorScheme.primary, + ), ), ); } +} - double _barHeight(int index) { - final phase = seconds * 0.72 + index * 0.58; - final height = 16.0 + (math.sin(phase).abs() * 28.0); - if (active) { - return height; +class _VoiceWavePainter extends CustomPainter { + const _VoiceWavePainter({ + required this.samples, + required this.active, + required this.color, + }); + + final List samples; + final bool active; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final centerY = size.height / 2; + final top = []; + final bottom = []; + + for (var index = 0; index < samples.length; index++) { + final x = samples.length == 1 + ? 0.0 + : index / (samples.length - 1) * size.width; + final envelope = math.sin(index / (samples.length - 1) * math.pi); + final amplitude = samples[index] * envelope * size.height * 0.46; + final yTop = centerY - amplitude; + final yBottom = centerY + amplitude; + + top.add(Offset(x, yTop)); + bottom.add(Offset(x, yBottom)); } - return height * 0.55; + final topPath = Path()..addPolygon(top, false); + final bottomPath = Path()..addPolygon(bottom, false); + final shape = Path() + ..addPolygon([...top, ...bottom.reversed], true) + ..close(); + + final glow = Paint() + ..color = color.withValues(alpha: active ? 0.18 : 0.08) + ..strokeWidth = 18 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10); + final stroke = Paint() + ..shader = LinearGradient( + colors: [ + color.withValues(alpha: active ? 0.35 : 0.16), + color.withValues(alpha: active ? 0.95 : 0.42), + const Color(0xFFE11D48).withValues(alpha: active ? 0.86 : 0.34), + ], + ).createShader(Offset.zero & size) + ..strokeWidth = 5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + final fill = Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + color.withValues(alpha: active ? 0.20 : 0.08), + const Color(0xFFE11D48).withValues(alpha: active ? 0.14 : 0.05), + ], + ).createShader(Offset.zero & size) + ..style = PaintingStyle.fill; + + canvas.drawPath(shape, fill); + canvas.drawPath(topPath, glow); + canvas.drawPath(bottomPath, glow); + canvas.drawPath(topPath, stroke); + canvas.drawPath(bottomPath, stroke); + canvas.drawCircle( + Offset(size.width / 2, centerY), + active ? 3.4 : 2.2, + Paint()..color = color.withValues(alpha: active ? 0.82 : 0.36), + ); + } + + @override + bool shouldRepaint(covariant _VoiceWavePainter oldDelegate) { + return true; } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d7ee0bc..3a6beec 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,10 @@ import Foundation import geolocator_apple import package_info_plus +import record_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 308e7d2..deb332e 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,7 @@ com.apple.security.personal-information.location + com.apple.security.device.audio-input + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 1e5c157..1aaabbc 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -26,6 +26,8 @@ $(PRODUCT_COPYRIGHT) NSLocationWhenInUseUsageDescription MapFlow uses your location to show places around you. + NSMicrophoneUsageDescription + MapFlow uses your microphone to record your voice review. NSMainNibFile MainMenu NSPrincipalClass diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 9148921..8b6ac4e 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -6,5 +6,7 @@ com.apple.security.personal-information.location + com.apple.security.device.audio-input + diff --git a/pubspec.lock b/pubspec.lock index a051bc9..1099402 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -624,6 +624,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 + url: "https://pub.dev" + source: hosted + version: "6.2.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" + url: "https://pub.dev" + source: hosted + version: "1.5.0" record_use: dependency: transitive description: @@ -632,6 +680,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" riverpod: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0b9d650..7ca9ef9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: http: ^1.6.0 web: ^1.1.1 geolocator: ^14.0.2 + record: ^6.2.0 dev_dependencies: flutter_test: