diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index b8c2a12..3a01cc1 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 _minimumInformationUnits = 9.0; + static const _minimumInformationUnits = 8.0; static const _nearbyPlaceRadiusMeters = 200; final _api = MapflowApi(); @@ -740,6 +740,9 @@ class _AddExperienceFlowState extends ConsumerState { var _micAllowed = true; var _ambientLevel = 0.008; var _voiceCeiling = 0.045; + var _noiseDb = -72.0; + var _voicePeakDb = -34.0; + var _liveLevel = 0.0; var _visualPhase = 0.0; DateTime? _lastAudioChunkAt; DateTime? _lastInformationAt; @@ -815,6 +818,8 @@ class _AddExperienceFlowState extends ConsumerState { setState(() { _micAllowed = true; _recording = true; + _liveLevel = 0; + _informationUnits = 0; }); } @@ -830,7 +835,10 @@ class _AddExperienceFlowState extends ConsumerState { if (!mounted) { return; } - setState(() => _recording = false); + setState(() { + _recording = false; + _liveLevel = 0; + }); } void _startIdleWave() { @@ -882,22 +890,19 @@ class _AddExperienceFlowState extends ConsumerState { void _handleAmplitude(double currentDb) { final now = DateTime.now(); - final normalized = ((currentDb + 54) / 54).clamp(0.0, 1.0); - final voicedAmount = math.pow(normalized, 0.72).toDouble(); - final informationDelta = _consumeInformationDelta(voicedAmount, now); + final level = _normalizeDbLevel(currentDb); + final informationDelta = _consumeInformationDelta(level, now); _visualPhase += 0.38; final samples = List.generate(8, (index) { final wave = math.sin(_visualPhase + index * 0.68) * 0.5 + 0.5; - return (0.10 + voicedAmount * 0.68 + wave * voicedAmount * 0.22).clamp( - 0.0, - 1.0, - ); + return (0.08 + level * 0.70 + wave * level * 0.24).clamp(0.0, 1.0); }); setState(() { _waveSamples ..removeRange(0, samples.length) ..addAll(samples); + _liveLevel = _smoothLevel(_liveLevel, level); _informationUnits = math.min( _minimumInformationUnits, _informationUnits + informationDelta, @@ -905,6 +910,29 @@ class _AddExperienceFlowState extends ConsumerState { }); } + double _normalizeDbLevel(double currentDb) { + final db = currentDb.clamp(-160.0, 0.0); + if (db < _noiseDb) { + _noiseDb = _noiseDb * 0.90 + db * 0.10; + } else { + _noiseDb = _noiseDb * 0.995 + db * 0.005; + } + if (db > _voicePeakDb) { + _voicePeakDb = _voicePeakDb * 0.72 + db * 0.28; + } else { + _voicePeakDb = math.max(_noiseDb + 18, _voicePeakDb * 0.998 + db * 0.002); + } + + final range = math.max(18.0, _voicePeakDb - _noiseDb); + final gated = ((db - _noiseDb - 5) / range).clamp(0.0, 1.0); + return math.pow(gated, 0.62).toDouble(); + } + + double _smoothLevel(double current, double next) { + final weight = next > current ? 0.46 : 0.18; + return current + (next - current) * weight; + } + double _consumeInformationDelta(double voicedAmount, DateTime now) { final previous = _lastInformationAt ?? now; _lastInformationAt = now; @@ -987,6 +1015,7 @@ class _AddExperienceFlowState extends ConsumerState { return visibleSignal.clamp(0.0, 1.0); }), ); + _liveLevel = _smoothLevel(_liveLevel, voicedAmount); _informationUnits = math.min( _minimumInformationUnits, _informationUnits + informationDelta, @@ -1009,6 +1038,7 @@ class _AddExperienceFlowState extends ConsumerState { isSubmitting: _submitting, micAllowed: _micAllowed, samples: _waveSamples, + liveLevel: _liveLevel, canContinue: widget.hasTelegramAuth && informationProgress >= 1, onToggleRecording: _toggleRecording, onNext: () async { @@ -1207,6 +1237,7 @@ class _VoiceStep extends StatelessWidget { required this.isSubmitting, required this.micAllowed, required this.samples, + required this.liveLevel, required this.canContinue, required this.onToggleRecording, required this.onNext, @@ -1219,6 +1250,7 @@ class _VoiceStep extends StatelessWidget { final bool isSubmitting; final bool micAllowed; final List samples; + final double liveLevel; final bool canContinue; final Future Function() onToggleRecording; final VoidCallback onNext; @@ -1247,6 +1279,7 @@ class _VoiceStep extends StatelessWidget { samples: samples, active: isRecording, progress: informationProgress, + liveLevel: liveLevel, ), ), ), @@ -1278,6 +1311,7 @@ class _VoiceStep extends StatelessWidget { ), _VoiceRecordButton( progress: informationProgress, + liveLevel: liveLevel, isRecording: isRecording, enabled: hasTelegramAuth && !isSubmitting, onPressed: onToggleRecording, @@ -1290,12 +1324,14 @@ class _VoiceStep extends StatelessWidget { class _VoiceRecordButton extends StatefulWidget { const _VoiceRecordButton({ required this.progress, + required this.liveLevel, required this.isRecording, required this.enabled, required this.onPressed, }); final double progress; + final double liveLevel; final bool isRecording; final bool enabled; final Future Function() onPressed; @@ -1349,12 +1385,16 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton> animation: _pulseController, builder: (context, child) { final pulse = widget.isRecording ? _pulseController.value : 0.0; + final level = widget.isRecording ? widget.liveLevel : 0.0; return Stack( alignment: Alignment.center, children: [ - for (final offset in const [0.0, 0.32]) + for (final offset in const [0.0, 0.32, 0.64]) Transform.scale( - scale: 1 + ((pulse + offset) % 1) * 0.28, + scale: + 1 + + ((pulse + offset) % 1) * (0.10 + level * 0.32) + + level * 0.10, child: Container( width: 130, height: 130, @@ -1363,10 +1403,11 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton> border: Border.all( color: const Color(0xFFFF2D75).withValues( alpha: widget.isRecording - ? (0.22 * (1 - ((pulse + offset) % 1))) + ? ((0.06 + level * 0.26) * + (1 - ((pulse + offset) % 1))) : 0, ), - width: 3, + width: 2.5 + level * 2.5, ), ), ), @@ -1416,11 +1457,13 @@ class _VoiceInformationField extends StatelessWidget { required this.samples, required this.active, required this.progress, + required this.liveLevel, }); final List samples; final bool active; final double progress; + final double liveLevel; @override Widget build(BuildContext context) { @@ -1462,6 +1505,7 @@ class _VoiceInformationField extends StatelessWidget { samples: samples, active: active, progress: progress, + liveLevel: liveLevel, ), ); }, @@ -1476,32 +1520,42 @@ class _VoiceInformationPainter extends CustomPainter { required this.samples, required this.active, required this.progress, + required this.liveLevel, }); final List samples; final bool active; final double progress; + final double liveLevel; @override void paint(Canvas canvas, Size size) { - 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); + const columns = 18; + const rows = 12; + const gap = 6.0; + final cellSize = math + .min( + (size.width - gap * (columns - 1)) / columns, + (size.height - gap * (rows - 1)) / rows, + ) + .clamp(8.0, 22.0); + final cellWidth = cellSize; + final cellHeight = cellSize; + final gridWidth = columns * cellWidth + (columns - 1) * gap; final gridHeight = rows * cellHeight + (rows - 1) * gap; + final startX = (size.width - gridWidth) / 2; final startY = (size.height - gridHeight) / 2; final activeCells = (progress * columns * rows).round(); final backgroundPaint = Paint() - ..color = Colors.white.withValues(alpha: 0.055) + ..color = Colors.white.withValues(alpha: 0.105) ..style = PaintingStyle.fill; final borderPaint = Paint() - ..color = Colors.white.withValues(alpha: 0.08) + ..color = Colors.white.withValues(alpha: 0.045) ..style = PaintingStyle.stroke ..strokeWidth = 1; final glowPaint = Paint() - ..color = const Color(0xFFFF2D75).withValues(alpha: active ? 0.22 : 0.10) + ..color = const Color(0xFFE11D48).withValues(alpha: active ? 0.18 : 0.08) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 18); for (var row = 0; row < rows; row++) { @@ -1514,65 +1568,68 @@ class _VoiceInformationPainter extends CustomPainter { final signal = samples.isEmpty ? 0.0 : samples[sampleIndex].clamp(0.0, 1.0); - final filled = cellIndex < activeCells; - final x = column * (cellWidth + gap); + final fillOrder = _cellOrder(cellIndex, columns * rows); + final filled = fillOrder < activeCells; + final x = startX + column * (cellWidth + gap); final y = startY + row * (cellHeight + gap); final rect = RRect.fromRectAndRadius( Rect.fromLTWH(x, y, cellWidth, cellHeight), - const Radius.circular(5), + const Radius.circular(4), ); canvas.drawRRect(rect, backgroundPaint); canvas.drawRRect(rect, borderPaint); final centerDistance = (row - (rows - 1) / 2).abs(); - final waveReach = 0.55 + signal * rows * 0.52; - final inWave = centerDistance <= waveReach; + final columnSignal = (signal * 0.78 + liveLevel * 0.22).clamp(0.0, 1.0); + final waveReach = 0.45 + columnSignal * rows * 0.44; + final hashLift = _hashUnit(cellIndex) * 0.55; + final inWave = centerDistance <= waveReach + hashLift; if (inWave) { final waveAlpha = active - ? 0.14 + signal * 0.26 - : 0.07 + signal * 0.08; + ? 0.10 + columnSignal * 0.18 + : 0.08 + columnSignal * 0.06; final wavePaint = Paint() - ..shader = LinearGradient( - colors: [ - const Color(0xFF38F5D3).withValues(alpha: waveAlpha), - const Color(0xFFFF2D75).withValues(alpha: waveAlpha * 0.92), - ], - ).createShader(rect.outerRect) + ..color = Colors.white.withValues(alpha: waveAlpha) ..style = PaintingStyle.fill; canvas.drawRRect(rect, wavePaint); } 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), + const Color( + 0xFFE11D48, + ).withValues(alpha: 0.78 + columnSignal * 0.18), + const Color( + 0xFFFF6B8A, + ).withValues(alpha: 0.58 + columnSignal * 0.18), ], ).createShader(rect.outerRect) ..style = PaintingStyle.fill; - canvas.drawRRect(rect.inflate(signal * 2.2), glowPaint); + canvas.drawRRect(rect.inflate(1.2 + columnSignal * 1.8), glowPaint); canvas.drawRRect(rect, paint); } } } } + int _cellOrder(int index, int total) { + return (((index + 11) * 73) % total).toInt(); + } + + double _hashUnit(int index) { + final value = math.sin(index * 12.9898 + 78.233) * 43758.5453; + return value - value.floorToDouble(); + } + @override bool shouldRepaint(covariant _VoiceInformationPainter oldDelegate) { return oldDelegate.samples != samples || oldDelegate.active != active || - oldDelegate.progress != progress; + oldDelegate.progress != progress || + oldDelegate.liveLevel != liveLevel; } }