From adc935b6cff553d4c4dd282455da3bdf1744389c Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Sat, 9 May 2026 17:51:42 +0700 Subject: [PATCH] Gate voice review by information fill --- lib/screens/mapflow_shell.dart | 210 ++++++++++++++++----------------- 1 file changed, 99 insertions(+), 111 deletions(-) diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index 5c1ac09..4ff3c3c 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -719,7 +719,7 @@ class AddExperienceFlow extends ConsumerStatefulWidget { } class _AddExperienceFlowState extends ConsumerState { - static const _minimumVoiceSeconds = 30; + static const _minimumInformationUnits = 18.0; static const _nearbyPlaceRadiusMeters = 200; final _api = MapflowApi(); @@ -731,9 +731,11 @@ class _AddExperienceFlowState extends ConsumerState { StreamSubscription? _audioStreamSub; var _step = 0; var _seconds = 0; + var _informationUnits = 0.0; var _recording = false; var _submitting = false; var _micAllowed = true; + DateTime? _lastAudioChunkAt; @override void initState() { @@ -787,6 +789,7 @@ class _AddExperienceFlowState extends ConsumerState { ), ); _audioStreamSub = stream.listen(_handleAudioChunk); + _lastAudioChunkAt = DateTime.now(); _timer = Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted) { return; @@ -808,6 +811,7 @@ class _AddExperienceFlowState extends ConsumerState { await _audioStreamSub?.cancel(); _audioStreamSub = null; await _recorder.stop(); + _lastAudioChunkAt = null; if (!mounted) { return; } @@ -842,6 +846,22 @@ class _AddExperienceFlowState extends ConsumerState { 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(0, (sum, value) => sum + value) / + peaks.length; + final informationDelta = voicedAmount * deltaSeconds; + if (!mounted) { return; } @@ -849,29 +869,29 @@ class _AddExperienceFlowState extends ConsumerState { _waveSamples ..removeRange(0, peaks.length) ..addAll(peaks); + _informationUnits = math.min( + _minimumInformationUnits, + _informationUnits + informationDelta, + ); }); } - String get _time => - '${(_seconds ~/ 60).toString().padLeft(2, '0')}:' - '${(_seconds % 60).toString().padLeft(2, '0')}'; - @override Widget build(BuildContext context) { final controller = ref.read(placeControllerProvider.notifier); + final informationProgress = (_informationUnits / _minimumInformationUnits) + .clamp(0.0, 1.0); final content = switch (_step) { 0 => _IntroStep(onNext: () => setState(() => _step = 1)), 1 => _VoiceStep( placeName: '', hasTelegramAuth: widget.hasTelegramAuth, - seconds: _seconds, - minimumSeconds: _minimumVoiceSeconds, - time: _time, + informationProgress: informationProgress, isRecording: _recording, isSubmitting: _submitting, micAllowed: _micAllowed, samples: _waveSamples, - canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds, + canContinue: widget.hasTelegramAuth && informationProgress >= 1, onToggleRecording: _toggleRecording, onNext: () async { if (_recording) { @@ -1064,9 +1084,7 @@ class _VoiceStep extends StatelessWidget { const _VoiceStep({ required this.placeName, required this.hasTelegramAuth, - required this.seconds, - required this.minimumSeconds, - required this.time, + required this.informationProgress, required this.isRecording, required this.isSubmitting, required this.micAllowed, @@ -1078,9 +1096,7 @@ class _VoiceStep extends StatelessWidget { final String placeName; final bool hasTelegramAuth; - final int seconds; - final int minimumSeconds; - final String time; + final double informationProgress; final bool isRecording; final bool isSubmitting; final bool micAllowed; @@ -1091,7 +1107,6 @@ class _VoiceStep extends StatelessWidget { @override Widget build(BuildContext context) { - final progress = (seconds / minimumSeconds).clamp(0.0, 1.0); final showNext = canContinue && !isRecording; return Column( @@ -1108,21 +1123,12 @@ class _VoiceStep extends StatelessWidget { ), 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( child: Center( - child: _VoiceWave( + child: _VoiceInformationField( samples: samples, active: isRecording, - progress: progress, + progress: informationProgress, ), ), ), @@ -1153,8 +1159,7 @@ class _VoiceStep extends StatelessWidget { : const SizedBox.shrink(key: ValueKey('empty-next')), ), _VoiceRecordButton( - time: time, - progress: progress, + progress: informationProgress, isRecording: isRecording, enabled: hasTelegramAuth && !isSubmitting, onPressed: onToggleRecording, @@ -1166,14 +1171,12 @@ class _VoiceStep extends StatelessWidget { class _VoiceRecordButton extends StatefulWidget { const _VoiceRecordButton({ - required this.time, required this.progress, required this.isRecording, required this.enabled, required this.onPressed, }); - final String time; final double progress; final bool isRecording; final bool enabled; @@ -1290,8 +1293,8 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton> } } -class _VoiceWave extends StatelessWidget { - const _VoiceWave({ +class _VoiceInformationField extends StatelessWidget { + const _VoiceInformationField({ required this.samples, required this.active, required this.progress, @@ -1337,7 +1340,7 @@ class _VoiceWave extends StatelessWidget { constraints.maxWidth, constraints.maxHeight.clamp(280.0, 520.0), ), - painter: _VoiceWavePainter( + painter: _VoiceInformationPainter( samples: samples, active: active, progress: progress, @@ -1350,8 +1353,8 @@ class _VoiceWave extends StatelessWidget { } } -class _VoiceWavePainter extends CustomPainter { - const _VoiceWavePainter({ +class _VoiceInformationPainter extends CustomPainter { + const _VoiceInformationPainter({ required this.samples, required this.active, required this.progress, @@ -1363,89 +1366,74 @@ class _VoiceWavePainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - final centerY = size.height / 2; - final widthStep = samples.length <= 1 - ? size.width - : size.width / (samples.length - 1); - final maxHeight = size.height * 0.46; - final idleHeight = maxHeight * (0.03 + progress * 0.06); + const columns = 16; + const rows = 10; + const gap = 7.0; + final cellWidth = (size.width - gap * (columns - 1)) / columns; + final cellHeight = math.min(24.0, (size.height - gap * (rows - 1)) / rows); + final gridHeight = rows * cellHeight + (rows - 1) * gap; + final startY = (size.height - gridHeight) / 2; + final activeCells = (progress * columns * rows).round(); - final fillPath = Path(); - final topPath = Path(); - final bottomPath = Path(); - final denominator = math.max(samples.length - 1, 1); + final backgroundPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.055) + ..style = PaintingStyle.fill; + 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++) { - 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; - final top = Offset(x, centerY - height); - final bottom = Offset(x, centerY + height); + for (var row = 0; row < rows; row++) { + for (var column = 0; column < columns; column++) { + final cellIndex = row * columns + column; + final sampleIndex = samples.isEmpty + ? 0 + : ((cellIndex / (columns * rows - 1)) * (samples.length - 1)) + .round(); + final signal = samples.isEmpty + ? 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) { - topPath.moveTo(top.dx, top.dy); - bottomPath.moveTo(bottom.dx, bottom.dy); - fillPath.moveTo(top.dx, top.dy); - } else { - topPath.lineTo(top.dx, top.dy); - bottomPath.lineTo(bottom.dx, bottom.dy); - fillPath.lineTo(top.dx, top.dy); + canvas.drawRRect(rect, backgroundPaint); + canvas.drawRRect(rect, borderPaint); + + if (filled) { + final warmth = (0.38 + signal * 0.62).clamp(0.0, 1.0); + final paint = Paint() + ..shader = LinearGradient( + 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 - bool shouldRepaint(covariant _VoiceWavePainter oldDelegate) { + bool shouldRepaint(covariant _VoiceInformationPainter oldDelegate) { return oldDelegate.samples != samples || oldDelegate.active != active || oldDelegate.progress != progress;