From 236658769392ba291d73800455f6a373b84e9678 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Wed, 13 May 2026 16:22:18 +0700 Subject: [PATCH] Restore voice information grid --- lib/screens/mapflow_shell.dart | 272 +++++++++++++++++---------------- 1 file changed, 143 insertions(+), 129 deletions(-) diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index ab55660..6bdb7bf 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -733,6 +733,7 @@ class _AddExperienceFlowState extends ConsumerState { noiseSuppress: true, ), ); + final _voiceSamples = List.filled(160, 0.0); Future>? _nearbyPlacesFuture; StreamSubscription? _amplitudeSub; @@ -790,6 +791,7 @@ class _AddExperienceFlowState extends ConsumerState { _recording = true; _liveLevel = 0; _informationUnits = 0; + _voiceSamples.fillRange(0, _voiceSamples.length, 0); }); } @@ -814,6 +816,9 @@ class _AddExperienceFlowState extends ConsumerState { final informationDelta = _consumeInformationDelta(level, now); setState(() { + _voiceSamples + ..removeAt(0) + ..add(level); _liveLevel = _smoothLevel(_liveLevel, level); _informationUnits = math.min( _minimumInformationUnits, @@ -870,7 +875,7 @@ class _AddExperienceFlowState extends ConsumerState { isRecording: _recording, isSubmitting: _submitting, micAllowed: _micAllowed, - waveController: _waveController, + samples: _voiceSamples, liveLevel: _liveLevel, canContinue: widget.hasTelegramAuth && informationProgress >= 1, onToggleRecording: _toggleRecording, @@ -1069,7 +1074,7 @@ class _VoiceStep extends StatelessWidget { required this.isRecording, required this.isSubmitting, required this.micAllowed, - required this.waveController, + required this.samples, required this.liveLevel, required this.canContinue, required this.onToggleRecording, @@ -1082,7 +1087,7 @@ class _VoiceStep extends StatelessWidget { final bool isRecording; final bool isSubmitting; final bool micAllowed; - final WaveformRecorderController waveController; + final List samples; final double liveLevel; final bool canContinue; final Future Function() onToggleRecording; @@ -1090,7 +1095,7 @@ class _VoiceStep extends StatelessWidget { @override Widget build(BuildContext context) { - final showNext = canContinue && !isRecording; + final canFinish = canContinue && !isRecording; return Column( children: [ @@ -1108,8 +1113,8 @@ class _VoiceStep extends StatelessWidget { ], Expanded( child: Center( - child: _LibraryWaveSurface( - controller: waveController, + child: _VoiceInformationField( + samples: samples, active: isRecording, progress: informationProgress, liveLevel: liveLevel, @@ -1125,29 +1130,13 @@ class _VoiceStep extends StatelessWidget { size: 22, ), ), - AnimatedSwitcher( - duration: const Duration(milliseconds: 180), - child: showNext - ? Padding( - key: const ValueKey('next'), - padding: const EdgeInsets.only(bottom: 14), - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: const Color(0xFF090613), - ), - onPressed: isSubmitting ? null : onNext, - child: Text(isSubmitting ? 'Отправляем' : 'Далее'), - ), - ) - : const SizedBox.shrink(key: ValueKey('empty-next')), - ), _VoiceRecordButton( progress: informationProgress, liveLevel: liveLevel, isRecording: isRecording, + canFinish: canFinish, enabled: hasTelegramAuth && !isSubmitting, - onPressed: onToggleRecording, + onPressed: canFinish ? onNext : onToggleRecording, ), ], ); @@ -1159,6 +1148,7 @@ class _VoiceRecordButton extends StatefulWidget { required this.progress, required this.liveLevel, required this.isRecording, + required this.canFinish, required this.enabled, required this.onPressed, }); @@ -1166,8 +1156,9 @@ class _VoiceRecordButton extends StatefulWidget { final double progress; final double liveLevel; final bool isRecording; + final bool canFinish; final bool enabled; - final Future Function() onPressed; + final VoidCallback onPressed; @override State<_VoiceRecordButton> createState() => _VoiceRecordButtonState(); @@ -1264,7 +1255,7 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton> width: 120, height: 120, child: FilledButton( - onPressed: widget.enabled ? () => widget.onPressed() : null, + onPressed: widget.enabled ? widget.onPressed : null, style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: const Color(0xFF090613), @@ -1275,7 +1266,11 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton> elevation: 0, ), child: Icon( - widget.isRecording ? Icons.pause_rounded : Icons.mic_rounded, + widget.canFinish + ? Icons.check_rounded + : widget.isRecording + ? Icons.pause_rounded + : Icons.mic_rounded, size: 48, ), ), @@ -1285,15 +1280,15 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton> } } -class _LibraryWaveSurface extends StatelessWidget { - const _LibraryWaveSurface({ - required this.controller, +class _VoiceInformationField extends StatelessWidget { + const _VoiceInformationField({ + required this.samples, required this.active, required this.progress, required this.liveLevel, }); - final WaveformRecorderController controller; + final List samples; final bool active; final double progress; final double liveLevel; @@ -1310,14 +1305,14 @@ class _LibraryWaveSurface extends StatelessWidget { BoxShadow( color: const Color( 0xFFFF2D75, - ).withValues(alpha: active ? 0.20 + liveLevel * 0.18 : 0.06), + ).withValues(alpha: active ? 0.18 + liveLevel * 0.18 : 0.06), blurRadius: 110, spreadRadius: 24, ), BoxShadow( color: const Color( 0xFF38F5D3, - ).withValues(alpha: active ? 0.10 + liveLevel * 0.12 : 0.04), + ).withValues(alpha: active ? 0.08 + liveLevel * 0.10 : 0.03), blurRadius: 130, spreadRadius: 14, ), @@ -1325,122 +1320,141 @@ class _LibraryWaveSurface extends StatelessWidget { ), child: const SizedBox.square(dimension: 210), ), - Positioned.fill( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: active - ? AnimatedWaveList( - key: ValueKey(controller.startTime), - stream: controller.amplitudeStream, - barBuilder: (animation, amplitude) => _VoiceWaveBar( - animation: animation, - amplitude: amplitude, - progress: progress, - ), - ) - : _IdleWaveBars(progress: progress), - ), + LayoutBuilder( + builder: (context, constraints) { + return CustomPaint( + size: Size( + constraints.maxWidth, + constraints.maxHeight.clamp(300.0, 520.0), + ), + painter: _VoiceInformationPainter( + samples: samples, + active: active, + progress: progress, + liveLevel: liveLevel, + ), + ); + }, ), ], ); } } -class _VoiceWaveBar extends StatelessWidget { - const _VoiceWaveBar({ - required this.animation, - required this.amplitude, +class _VoiceInformationPainter extends CustomPainter { + const _VoiceInformationPainter({ + required this.samples, + required this.active, required this.progress, + required this.liveLevel, }); - final Animation animation; - final Amplitude amplitude; + final List samples; + final bool active; final double progress; + final double liveLevel; @override - Widget build(BuildContext context) { - final level = _amplitudeLevel(amplitude.current); - final height = 14 + level * 210; - final color = Color.lerp( - Colors.white.withValues(alpha: 0.28), - const Color(0xFFFF2D75), - progress.clamp(0.0, 1.0), - )!; + void paint(Canvas canvas, Size size) { + 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 gridWidth = columns * cellSize + (columns - 1) * gap; + final gridHeight = rows * cellSize + (rows - 1) * gap; + final startX = (size.width - gridWidth) / 2; + final startY = (size.height - gridHeight) / 2; + final totalCells = columns * rows; + final activeCells = (progress * totalCells).round(); - return SizeTransition( - sizeFactor: animation, - axis: Axis.horizontal, - child: Align( - alignment: Alignment.center, - child: Container( - width: 5, - height: height, - margin: const EdgeInsets.symmetric(horizontal: 2.5), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, + final backgroundPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.10) + ..style = PaintingStyle.fill; + final borderPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.05) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + final glowPaint = Paint() + ..color = const Color(0xFFFF2D75).withValues(alpha: active ? 0.18 : 0.08) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 18); + + 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 / (totalCells - 1)) * (samples.length - 1)).round(); + final signal = samples.isEmpty + ? 0.0 + : samples[sampleIndex].clamp(0.0, 1.0); + final filled = _cellOrder(cellIndex, totalCells) < activeCells; + final x = startX + column * (cellSize + gap); + final y = startY + row * (cellSize + gap); + final rect = RRect.fromRectAndRadius( + Rect.fromLTWH(x, y, cellSize, cellSize), + const Radius.circular(4), + ); + + canvas.drawRRect(rect, backgroundPaint); + canvas.drawRRect(rect, borderPaint); + + final centerDistance = (row - (rows - 1) / 2).abs(); + final columnSignal = (signal * 0.80 + liveLevel * 0.20).clamp(0.0, 1.0); + final waveReach = 0.5 + columnSignal * rows * 0.42; + final inWave = + centerDistance <= waveReach + _hashUnit(cellIndex) * 0.55; + if (inWave) { + canvas.drawRRect( + rect, + Paint() + ..color = Colors.white.withValues( + alpha: active + ? 0.09 + columnSignal * 0.18 + : 0.06 + columnSignal * 0.06, + ), + ); + } + + if (filled) { + final paint = Paint() + ..shader = LinearGradient( + begin: Alignment.bottomLeft, + end: Alignment.topRight, colors: [ - color.withValues(alpha: 0.42), - color, - const Color(0xFFFF7A90), + const Color( + 0xFFE11D48, + ).withValues(alpha: 0.76 + columnSignal * 0.18), + const Color( + 0xFFFF6B8A, + ).withValues(alpha: 0.56 + columnSignal * 0.18), ], - ), - boxShadow: [ - BoxShadow( - color: color.withValues(alpha: 0.38), - blurRadius: 18, - spreadRadius: 1, - ), - ], - ), - ), - ), - ); + ).createShader(rect.outerRect) + ..style = PaintingStyle.fill; + canvas.drawRRect(rect.inflate(1.2 + columnSignal * 1.8), glowPaint); + canvas.drawRRect(rect, paint); + } + } + } } - double _amplitudeLevel(double db) { - final normalized = ((db.clamp(-76.0, -6.0) + 76.0) / 70.0).clamp(0.0, 1.0); - return math.pow(normalized, 0.62).toDouble(); + int _cellOrder(int index, int total) => ((index + 11) * 73) % total; + + double _hashUnit(int index) { + final value = math.sin(index * 12.9898 + 78.233) * 43758.5453; + return value - value.floorToDouble(); } -} - -class _IdleWaveBars extends StatelessWidget { - const _IdleWaveBars({required this.progress}); - - final double progress; @override - Widget build(BuildContext context) { - final color = Color.lerp( - Colors.white.withValues(alpha: 0.18), - const Color(0xFFFF2D75), - progress.clamp(0.0, 1.0), - )!; - - return Center( - child: SizedBox( - height: 220, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: List.generate(26, (index) { - final distance = (index - 12.5).abs(); - final height = 18 + math.max(0.0, 1 - distance / 13) * 56; - return Container( - width: 4, - height: height, - margin: const EdgeInsets.symmetric(horizontal: 3), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(20), - ), - ); - }), - ), - ), - ); + bool shouldRepaint(covariant _VoiceInformationPainter oldDelegate) { + return oldDelegate.samples != samples || + oldDelegate.active != active || + oldDelegate.progress != progress || + oldDelegate.liveLevel != liveLevel; } }