Add live microphone waveform
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m3s
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m3s
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<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_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
|
||||
<application
|
||||
android:label="mapflow"
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<true/>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<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>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
||||
@@ -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<AddExperienceFlow> {
|
||||
static const _minimumVoiceSeconds = 30;
|
||||
|
||||
final _recorder = AudioRecorder();
|
||||
final _waveSamples = List<double>.filled(64, 0.04);
|
||||
|
||||
Timer? _timer;
|
||||
StreamSubscription<Uint8List>? _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<AddExperienceFlow> {
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_audioStreamSub?.cancel();
|
||||
_recorder.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleRecording() {
|
||||
setState(() => _recording = !_recording);
|
||||
Future<void> _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<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), (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _seconds += 1);
|
||||
ref
|
||||
.read(placeControllerProvider.notifier)
|
||||
.setReviewDuration(Duration(seconds: _seconds));
|
||||
});
|
||||
|
||||
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 =>
|
||||
@@ -783,6 +855,8 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
||||
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<AddExperienceFlow> {
|
||||
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<double> samples;
|
||||
final bool canContinue;
|
||||
final VoidCallback onToggleRecording;
|
||||
final Future<void> 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<double> 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<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));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -10,5 +10,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<string>$(PRODUCT_COPYRIGHT)</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<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>
|
||||
<string>MainMenu</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
64
pubspec.lock
64
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user