Add live microphone waveform
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m3s

This commit is contained in:
Ruslan Bakiev
2026-05-09 14:08:27 +07:00
parent 56703c887f
commit b819b51c1f
9 changed files with 268 additions and 55 deletions

View File

@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<application <application
android:label="mapflow" android:label="mapflow"

View File

@@ -26,6 +26,8 @@
<true/> <true/>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>MapFlow uses your location to show places around you.</string> <string>MapFlow uses your location to show places around you.</string>
<key>NSMicrophoneUsageDescription</key>
<string>MapFlow uses your microphone to record your voice review.</string>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>

View File

@@ -1,10 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 '../api/mapflow_api.dart';
import '../auth/telegram_login_button.dart'; import '../auth/telegram_login_button.dart';
@@ -719,11 +721,16 @@ class AddExperienceFlow extends ConsumerStatefulWidget {
class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> { class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
static const _minimumVoiceSeconds = 30; static const _minimumVoiceSeconds = 30;
final _recorder = AudioRecorder();
final _waveSamples = List<double>.filled(64, 0.04);
Timer? _timer; Timer? _timer;
StreamSubscription<Uint8List>? _audioStreamSub;
var _step = 0; var _step = 0;
var _seconds = 0; var _seconds = 0;
var _recording = false; var _recording = false;
var _submitting = false; var _submitting = false;
var _micAllowed = true;
PlaceRecommendation? _selectedPlace; PlaceRecommendation? _selectedPlace;
@override @override
@@ -734,12 +741,38 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
_audioStreamSub?.cancel();
_recorder.dispose();
super.dispose(); super.dispose();
} }
void _toggleRecording() { Future<void> _toggleRecording() async {
setState(() => _recording = !_recording);
if (_recording) { if (_recording) {
await _stopRecording();
return;
}
await _startRecording();
}
Future<void> _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), (_) { _timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) { if (!mounted) {
return; return;
@@ -749,9 +782,48 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
.read(placeControllerProvider.notifier) .read(placeControllerProvider.notifier)
.setReviewDuration(Duration(seconds: _seconds)); .setReviewDuration(Duration(seconds: _seconds));
}); });
} else {
_timer?.cancel(); setState(() {
_micAllowed = true;
_recording = true;
});
} }
Future<void> _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 => String get _time =>
@@ -783,6 +855,8 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
time: _time, time: _time,
isRecording: _recording, isRecording: _recording,
isSubmitting: _submitting, isSubmitting: _submitting,
micAllowed: _micAllowed,
samples: _waveSamples,
canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds, canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds,
onToggleRecording: _toggleRecording, onToggleRecording: _toggleRecording,
onNext: () async { onNext: () async {
@@ -790,6 +864,9 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
if (selectedPlace == null) { if (selectedPlace == null) {
return; return;
} }
if (_recording) {
await _stopRecording();
}
setState(() => _submitting = true); setState(() => _submitting = true);
controller.setReviewPlace(selectedPlace.name); controller.setReviewPlace(selectedPlace.name);
await controller.publishReview(coordinate: selectedPlace.coordinate); await controller.publishReview(coordinate: selectedPlace.coordinate);
@@ -935,6 +1012,8 @@ class _VoiceStep extends StatelessWidget {
required this.time, required this.time,
required this.isRecording, required this.isRecording,
required this.isSubmitting, required this.isSubmitting,
required this.micAllowed,
required this.samples,
required this.canContinue, required this.canContinue,
required this.onToggleRecording, required this.onToggleRecording,
required this.onNext, required this.onNext,
@@ -947,8 +1026,10 @@ class _VoiceStep extends StatelessWidget {
final String time; final String time;
final bool isRecording; final bool isRecording;
final bool isSubmitting; final bool isSubmitting;
final bool micAllowed;
final List<double> samples;
final bool canContinue; final bool canContinue;
final VoidCallback onToggleRecording; final Future<void> Function() onToggleRecording;
final VoidCallback onNext; final VoidCallback onNext;
@override @override
@@ -965,26 +1046,17 @@ class _VoiceStep extends StatelessWidget {
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(hasTelegramAuth ? 'Минимум $minimumSeconds секунд' : ''),
hasTelegramAuth
? 'Минимум $minimumSeconds секунд'
: 'Открой через Telegram',
textAlign: TextAlign.center,
),
const SizedBox(height: 26), const SizedBox(height: 26),
if (isRecording || seconds > 0) ...[ _VoiceWave(samples: samples, active: isRecording),
_VoiceWave(seconds: seconds, active: isRecording), const SizedBox(height: 24),
const SizedBox(height: 20),
] else ...[
const SizedBox(height: 70),
],
SizedBox( SizedBox(
width: 132, width: 132,
height: 132, height: 132,
child: FilledButton( child: FilledButton(
onPressed: isSubmitting || !hasTelegramAuth onPressed: isSubmitting || !hasTelegramAuth
? null ? null
: onToggleRecording, : () => onToggleRecording(),
style: FilledButton.styleFrom(shape: const CircleBorder()), style: FilledButton.styleFrom(shape: const CircleBorder()),
child: Icon(isRecording ? Icons.stop : Icons.mic, size: 54), child: Icon(isRecording ? Icons.stop : Icons.mic, size: 54),
), ),
@@ -1000,6 +1072,10 @@ class _VoiceStep extends StatelessWidget {
LinearProgressIndicator( LinearProgressIndicator(
value: (seconds / minimumSeconds).clamp(0.0, 1.0), value: (seconds / minimumSeconds).clamp(0.0, 1.0),
), ),
if (!micAllowed) ...[
const SizedBox(height: 12),
const Icon(Icons.mic_off_outlined, size: 22),
],
const Spacer(), const Spacer(),
], ],
), ),
@@ -1012,45 +1088,106 @@ class _VoiceStep extends StatelessWidget {
} }
class _VoiceWave 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<double> samples;
final bool active; final bool active;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
return SizedBox( return SizedBox(
height: 50, height: 112,
child: Row( width: double.infinity,
mainAxisAlignment: MainAxisAlignment.center, child: CustomPaint(
crossAxisAlignment: CrossAxisAlignment.center, painter: _VoiceWavePainter(
children: [ samples: samples,
for (var index = 0; index < 23; index++) active: active,
AnimatedContainer( color: Theme.of(context).colorScheme.primary,
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),
), ),
), ),
],
),
); );
} }
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;
} }
return height * 0.55; class _VoiceWavePainter extends CustomPainter {
const _VoiceWavePainter({
required this.samples,
required this.active,
required this.color,
});
final List<double> samples;
final bool active;
final Color color;
@override
void paint(Canvas canvas, Size size) {
final centerY = size.height / 2;
final top = <Offset>[];
final bottom = <Offset>[];
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));
}
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;
} }
} }

View File

@@ -7,8 +7,10 @@ import Foundation
import geolocator_apple import geolocator_apple
import package_info_plus import package_info_plus
import record_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
} }

View File

@@ -10,5 +10,7 @@
<true/> <true/>
<key>com.apple.security.personal-information.location</key> <key>com.apple.security.personal-information.location</key>
<true/> <true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -26,6 +26,8 @@
<string>$(PRODUCT_COPYRIGHT)</string> <string>$(PRODUCT_COPYRIGHT)</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>MapFlow uses your location to show places around you.</string> <string>MapFlow uses your location to show places around you.</string>
<key>NSMicrophoneUsageDescription</key>
<string>MapFlow uses your microphone to record your voice review.</string>
<key>NSMainNibFile</key> <key>NSMainNibFile</key>
<string>MainMenu</string> <string>MainMenu</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>

View File

@@ -6,5 +6,7 @@
<true/> <true/>
<key>com.apple.security.personal-information.location</key> <key>com.apple.security.personal-information.location</key>
<true/> <true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -624,6 +624,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: record_use:
dependency: transitive dependency: transitive
description: description:
@@ -632,6 +680,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.0" 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: riverpod:
dependency: transitive dependency: transitive
description: description:

View File

@@ -40,6 +40,7 @@ dependencies:
http: ^1.6.0 http: ^1.6.0
web: ^1.1.1 web: ^1.1.1
geolocator: ^14.0.2 geolocator: ^14.0.2
record: ^6.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: