Gate voice review by information fill
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 1m45s

This commit is contained in:
Ruslan Bakiev
2026-05-09 17:51:42 +07:00
parent 6055a101e8
commit adc935b6cf

View File

@@ -719,7 +719,7 @@ class AddExperienceFlow extends ConsumerStatefulWidget {
} }
class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> { class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
static const _minimumVoiceSeconds = 30; static const _minimumInformationUnits = 18.0;
static const _nearbyPlaceRadiusMeters = 200; static const _nearbyPlaceRadiusMeters = 200;
final _api = MapflowApi(); final _api = MapflowApi();
@@ -731,9 +731,11 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
StreamSubscription<Uint8List>? _audioStreamSub; StreamSubscription<Uint8List>? _audioStreamSub;
var _step = 0; var _step = 0;
var _seconds = 0; var _seconds = 0;
var _informationUnits = 0.0;
var _recording = false; var _recording = false;
var _submitting = false; var _submitting = false;
var _micAllowed = true; var _micAllowed = true;
DateTime? _lastAudioChunkAt;
@override @override
void initState() { void initState() {
@@ -787,6 +789,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
), ),
); );
_audioStreamSub = stream.listen(_handleAudioChunk); _audioStreamSub = stream.listen(_handleAudioChunk);
_lastAudioChunkAt = DateTime.now();
_timer = Timer.periodic(const Duration(seconds: 1), (_) { _timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) { if (!mounted) {
return; return;
@@ -808,6 +811,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
await _audioStreamSub?.cancel(); await _audioStreamSub?.cancel();
_audioStreamSub = null; _audioStreamSub = null;
await _recorder.stop(); await _recorder.stop();
_lastAudioChunkAt = null;
if (!mounted) { if (!mounted) {
return; return;
} }
@@ -842,6 +846,22 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
return; return;
} }
final now = DateTime.now();
final previousChunkAt = _lastAudioChunkAt ?? now;
_lastAudioChunkAt = now;
final deltaSeconds =
now.difference(previousChunkAt).inMilliseconds.clamp(20, 300) / 1000;
const noiseFloor = 0.08;
final voicedAmount =
peaks
.map(
(peak) =>
((peak - noiseFloor) / (1 - noiseFloor)).clamp(0.0, 1.0),
)
.fold<double>(0, (sum, value) => sum + value) /
peaks.length;
final informationDelta = voicedAmount * deltaSeconds;
if (!mounted) { if (!mounted) {
return; return;
} }
@@ -849,29 +869,29 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
_waveSamples _waveSamples
..removeRange(0, peaks.length) ..removeRange(0, peaks.length)
..addAll(peaks); ..addAll(peaks);
_informationUnits = math.min(
_minimumInformationUnits,
_informationUnits + informationDelta,
);
}); });
} }
String get _time =>
'${(_seconds ~/ 60).toString().padLeft(2, '0')}:'
'${(_seconds % 60).toString().padLeft(2, '0')}';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final controller = ref.read(placeControllerProvider.notifier); final controller = ref.read(placeControllerProvider.notifier);
final informationProgress = (_informationUnits / _minimumInformationUnits)
.clamp(0.0, 1.0);
final content = switch (_step) { final content = switch (_step) {
0 => _IntroStep(onNext: () => setState(() => _step = 1)), 0 => _IntroStep(onNext: () => setState(() => _step = 1)),
1 => _VoiceStep( 1 => _VoiceStep(
placeName: '', placeName: '',
hasTelegramAuth: widget.hasTelegramAuth, hasTelegramAuth: widget.hasTelegramAuth,
seconds: _seconds, informationProgress: informationProgress,
minimumSeconds: _minimumVoiceSeconds,
time: _time,
isRecording: _recording, isRecording: _recording,
isSubmitting: _submitting, isSubmitting: _submitting,
micAllowed: _micAllowed, micAllowed: _micAllowed,
samples: _waveSamples, samples: _waveSamples,
canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds, canContinue: widget.hasTelegramAuth && informationProgress >= 1,
onToggleRecording: _toggleRecording, onToggleRecording: _toggleRecording,
onNext: () async { onNext: () async {
if (_recording) { if (_recording) {
@@ -1064,9 +1084,7 @@ class _VoiceStep extends StatelessWidget {
const _VoiceStep({ const _VoiceStep({
required this.placeName, required this.placeName,
required this.hasTelegramAuth, required this.hasTelegramAuth,
required this.seconds, required this.informationProgress,
required this.minimumSeconds,
required this.time,
required this.isRecording, required this.isRecording,
required this.isSubmitting, required this.isSubmitting,
required this.micAllowed, required this.micAllowed,
@@ -1078,9 +1096,7 @@ class _VoiceStep extends StatelessWidget {
final String placeName; final String placeName;
final bool hasTelegramAuth; final bool hasTelegramAuth;
final int seconds; final double informationProgress;
final int minimumSeconds;
final String time;
final bool isRecording; final bool isRecording;
final bool isSubmitting; final bool isSubmitting;
final bool micAllowed; final bool micAllowed;
@@ -1091,7 +1107,6 @@ class _VoiceStep extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final progress = (seconds / minimumSeconds).clamp(0.0, 1.0);
final showNext = canContinue && !isRecording; final showNext = canContinue && !isRecording;
return Column( return Column(
@@ -1108,21 +1123,12 @@ class _VoiceStep extends StatelessWidget {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
], ],
const SizedBox(height: 8),
Text(
time,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
Expanded( Expanded(
child: Center( child: Center(
child: _VoiceWave( child: _VoiceInformationField(
samples: samples, samples: samples,
active: isRecording, active: isRecording,
progress: progress, progress: informationProgress,
), ),
), ),
), ),
@@ -1153,8 +1159,7 @@ class _VoiceStep extends StatelessWidget {
: const SizedBox.shrink(key: ValueKey('empty-next')), : const SizedBox.shrink(key: ValueKey('empty-next')),
), ),
_VoiceRecordButton( _VoiceRecordButton(
time: time, progress: informationProgress,
progress: progress,
isRecording: isRecording, isRecording: isRecording,
enabled: hasTelegramAuth && !isSubmitting, enabled: hasTelegramAuth && !isSubmitting,
onPressed: onToggleRecording, onPressed: onToggleRecording,
@@ -1166,14 +1171,12 @@ class _VoiceStep extends StatelessWidget {
class _VoiceRecordButton extends StatefulWidget { class _VoiceRecordButton extends StatefulWidget {
const _VoiceRecordButton({ const _VoiceRecordButton({
required this.time,
required this.progress, required this.progress,
required this.isRecording, required this.isRecording,
required this.enabled, required this.enabled,
required this.onPressed, required this.onPressed,
}); });
final String time;
final double progress; final double progress;
final bool isRecording; final bool isRecording;
final bool enabled; final bool enabled;
@@ -1290,8 +1293,8 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton>
} }
} }
class _VoiceWave extends StatelessWidget { class _VoiceInformationField extends StatelessWidget {
const _VoiceWave({ const _VoiceInformationField({
required this.samples, required this.samples,
required this.active, required this.active,
required this.progress, required this.progress,
@@ -1337,7 +1340,7 @@ class _VoiceWave extends StatelessWidget {
constraints.maxWidth, constraints.maxWidth,
constraints.maxHeight.clamp(280.0, 520.0), constraints.maxHeight.clamp(280.0, 520.0),
), ),
painter: _VoiceWavePainter( painter: _VoiceInformationPainter(
samples: samples, samples: samples,
active: active, active: active,
progress: progress, progress: progress,
@@ -1350,8 +1353,8 @@ class _VoiceWave extends StatelessWidget {
} }
} }
class _VoiceWavePainter extends CustomPainter { class _VoiceInformationPainter extends CustomPainter {
const _VoiceWavePainter({ const _VoiceInformationPainter({
required this.samples, required this.samples,
required this.active, required this.active,
required this.progress, required this.progress,
@@ -1363,89 +1366,74 @@ class _VoiceWavePainter extends CustomPainter {
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final centerY = size.height / 2; const columns = 16;
final widthStep = samples.length <= 1 const rows = 10;
? size.width const gap = 7.0;
: size.width / (samples.length - 1); final cellWidth = (size.width - gap * (columns - 1)) / columns;
final maxHeight = size.height * 0.46; final cellHeight = math.min(24.0, (size.height - gap * (rows - 1)) / rows);
final idleHeight = maxHeight * (0.03 + progress * 0.06); final gridHeight = rows * cellHeight + (rows - 1) * gap;
final startY = (size.height - gridHeight) / 2;
final activeCells = (progress * columns * rows).round();
final fillPath = Path(); final backgroundPaint = Paint()
final topPath = Path(); ..color = Colors.white.withValues(alpha: 0.055)
final bottomPath = Path(); ..style = PaintingStyle.fill;
final denominator = math.max(samples.length - 1, 1); final borderPaint = Paint()
..color = Colors.white.withValues(alpha: 0.08)
..style = PaintingStyle.stroke
..strokeWidth = 1;
final glowPaint = Paint()
..color = const Color(0xFFFF2D75).withValues(alpha: active ? 0.22 : 0.10)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 18);
for (var index = 0; index < samples.length; index++) { for (var row = 0; row < rows; row++) {
final x = index * widthStep; for (var column = 0; column < columns; column++) {
final envelope = math.sin(index / denominator * math.pi); final cellIndex = row * columns + column;
final raw = samples[index].clamp(0.0, 1.0); final sampleIndex = samples.isEmpty
final height = active ? 0
? math.max(raw * envelope * maxHeight, idleHeight) : ((cellIndex / (columns * rows - 1)) * (samples.length - 1))
: idleHeight * envelope; .round();
final top = Offset(x, centerY - height); final signal = samples.isEmpty
final bottom = Offset(x, centerY + height); ? 0.0
: samples[sampleIndex].clamp(0.0, 1.0);
final filled = cellIndex < activeCells;
final x = column * (cellWidth + gap);
final y = startY + row * (cellHeight + gap);
final rect = RRect.fromRectAndRadius(
Rect.fromLTWH(x, y, cellWidth, cellHeight),
const Radius.circular(5),
);
if (index == 0) { canvas.drawRRect(rect, backgroundPaint);
topPath.moveTo(top.dx, top.dy); canvas.drawRRect(rect, borderPaint);
bottomPath.moveTo(bottom.dx, bottom.dy);
fillPath.moveTo(top.dx, top.dy); if (filled) {
} else { final warmth = (0.38 + signal * 0.62).clamp(0.0, 1.0);
topPath.lineTo(top.dx, top.dy); final paint = Paint()
bottomPath.lineTo(bottom.dx, bottom.dy); ..shader = LinearGradient(
fillPath.lineTo(top.dx, top.dy); colors: [
Color.lerp(
const Color(0xFF38F5D3),
const Color(0xFFFF2D75),
warmth,
)!.withValues(alpha: 0.72 + signal * 0.24),
Color.lerp(
const Color(0xFF7C5CFF),
const Color(0xFFFFE4EF),
warmth,
)!.withValues(alpha: 0.42 + signal * 0.22),
],
).createShader(rect.outerRect)
..style = PaintingStyle.fill;
canvas.drawRRect(rect.inflate(signal * 2.2), glowPaint);
canvas.drawRRect(rect, paint);
}
} }
} }
for (var index = samples.length - 1; index >= 0; index--) {
final x = index * widthStep;
final envelope = math.sin(index / denominator * math.pi);
final raw = samples[index].clamp(0.0, 1.0);
final height = active
? math.max(raw * envelope * maxHeight, idleHeight)
: idleHeight * envelope;
fillPath.lineTo(x, centerY + height);
}
fillPath.close();
final glowPaint = Paint()
..color = const Color(0xFFFF2D75).withValues(alpha: active ? 0.20 : 0.08)
..strokeWidth = 16
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 14);
final strokePaint = Paint()
..shader = const LinearGradient(
colors: [
Color(0xFF38F5D3),
Color(0xFFFFFFFF),
Color(0xFFFF2D75),
Color(0xFF7C5CFF),
],
).createShader(Offset.zero & size)
..strokeWidth = 4.8
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
final fillPaint = Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFF38F5D3).withValues(alpha: active ? 0.12 : 0.04),
const Color(0xFFFF2D75).withValues(alpha: active ? 0.16 : 0.05),
],
).createShader(Offset.zero & size)
..style = PaintingStyle.fill;
canvas.drawPath(fillPath, fillPaint);
canvas.drawPath(topPath, glowPaint);
canvas.drawPath(bottomPath, glowPaint);
canvas.drawPath(topPath, strokePaint);
canvas.drawPath(bottomPath, strokePaint);
} }
@override @override
bool shouldRepaint(covariant _VoiceWavePainter oldDelegate) { bool shouldRepaint(covariant _VoiceInformationPainter oldDelegate) {
return oldDelegate.samples != samples || return oldDelegate.samples != samples ||
oldDelegate.active != active || oldDelegate.active != active ||
oldDelegate.progress != progress; oldDelegate.progress != progress;