diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index 718de8b..ab55660 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -733,11 +733,9 @@ class _AddExperienceFlowState extends ConsumerState { noiseSuppress: true, ), ); - final _voiceSamples = List.filled(160, 0.0); Future>? _nearbyPlacesFuture; StreamSubscription? _amplitudeSub; - Timer? _visualTimer; var _step = 0; var _informationUnits = 0.0; var _recording = false; @@ -746,9 +744,7 @@ class _AddExperienceFlowState extends ConsumerState { var _noiseDb = -72.0; var _voicePeakDb = -34.0; var _liveLevel = 0.0; - var _visualPhase = 0.0; DateTime? _lastInformationAt; - DateTime? _lastAmplitudeAt; @override void initState() { @@ -758,7 +754,6 @@ class _AddExperienceFlowState extends ConsumerState { @override void dispose() { _amplitudeSub?.cancel(); - _visualTimer?.cancel(); _waveController.dispose(); super.dispose(); } @@ -788,27 +783,21 @@ class _AddExperienceFlowState extends ConsumerState { await _waveController.startRecording(); await _amplitudeSub?.cancel(); _lastInformationAt = DateTime.now(); - _lastAmplitudeAt = null; _amplitudeSub = _waveController.amplitudeStream.listen(_handleAmplitude); - _startVisualTick(); setState(() { _micAllowed = true; _recording = true; _liveLevel = 0; _informationUnits = 0; - _voiceSamples.fillRange(0, _voiceSamples.length, 0); }); } Future _stopRecording() async { await _amplitudeSub?.cancel(); _amplitudeSub = null; - _visualTimer?.cancel(); - _visualTimer = null; await _waveController.stopRecording(); _lastInformationAt = null; - _lastAmplitudeAt = null; if (!mounted) { return; } @@ -822,16 +811,9 @@ class _AddExperienceFlowState extends ConsumerState { final currentDb = amplitude.current; final now = DateTime.now(); final level = _normalizeDbLevel(currentDb); - final informationLevel = currentDb > -64 - ? math.max(0.22, level) - : level * 0.35; - final informationDelta = _consumeInformationDelta(informationLevel, now); - _lastAmplitudeAt = now; + final informationDelta = _consumeInformationDelta(level, now); setState(() { - _voiceSamples - ..removeAt(0) - ..add(math.max(0.04, level)); _liveLevel = _smoothLevel(_liveLevel, level); _informationUnits = math.min( _minimumInformationUnits, @@ -843,30 +825,6 @@ class _AddExperienceFlowState extends ConsumerState { .setReviewDuration(_waveController.timeElapsed); } - void _startVisualTick() { - _visualTimer?.cancel(); - _visualTimer = Timer.periodic(const Duration(milliseconds: 70), (_) { - if (!mounted || !_recording) { - return; - } - - _visualPhase += 0.34; - final hasFreshAmplitude = - _lastAmplitudeAt != null && - DateTime.now().difference(_lastAmplitudeAt!).inMilliseconds < 160; - final baseLevel = hasFreshAmplitude ? _liveLevel : 0.10; - final wave = - (math.sin(_visualPhase) * 0.5 + 0.5) * (0.16 + baseLevel * 0.34); - final visualLevel = (0.06 + baseLevel * 0.70 + wave).clamp(0.0, 1.0); - - setState(() { - _voiceSamples - ..removeAt(0) - ..add(visualLevel); - }); - }); - } - double _normalizeDbLevel(double currentDb) { final db = currentDb.clamp(-160.0, 0.0); if (db < _noiseDb) { @@ -912,7 +870,7 @@ class _AddExperienceFlowState extends ConsumerState { isRecording: _recording, isSubmitting: _submitting, micAllowed: _micAllowed, - samples: _voiceSamples, + waveController: _waveController, liveLevel: _liveLevel, canContinue: widget.hasTelegramAuth && informationProgress >= 1, onToggleRecording: _toggleRecording, @@ -1111,7 +1069,7 @@ class _VoiceStep extends StatelessWidget { required this.isRecording, required this.isSubmitting, required this.micAllowed, - required this.samples, + required this.waveController, required this.liveLevel, required this.canContinue, required this.onToggleRecording, @@ -1124,7 +1082,7 @@ class _VoiceStep extends StatelessWidget { final bool isRecording; final bool isSubmitting; final bool micAllowed; - final List samples; + final WaveformRecorderController waveController; final double liveLevel; final bool canContinue; final Future Function() onToggleRecording; @@ -1132,7 +1090,7 @@ class _VoiceStep extends StatelessWidget { @override Widget build(BuildContext context) { - final canFinish = canContinue; + final showNext = canContinue && !isRecording; return Column( children: [ @@ -1150,8 +1108,8 @@ class _VoiceStep extends StatelessWidget { ], Expanded( child: Center( - child: _VoiceInformationField( - samples: samples, + child: _LibraryWaveSurface( + controller: waveController, active: isRecording, progress: informationProgress, liveLevel: liveLevel, @@ -1167,13 +1125,29 @@ 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: canFinish ? onNext : onToggleRecording, + onPressed: onToggleRecording, ), ], ); @@ -1185,7 +1159,6 @@ class _VoiceRecordButton extends StatefulWidget { required this.progress, required this.liveLevel, required this.isRecording, - required this.canFinish, required this.enabled, required this.onPressed, }); @@ -1193,9 +1166,8 @@ class _VoiceRecordButton extends StatefulWidget { final double progress; final double liveLevel; final bool isRecording; - final bool canFinish; final bool enabled; - final VoidCallback onPressed; + final Future Function() onPressed; @override State<_VoiceRecordButton> createState() => _VoiceRecordButtonState(); @@ -1292,7 +1264,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), @@ -1303,11 +1275,7 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton> elevation: 0, ), child: Icon( - widget.canFinish - ? Icons.check_rounded - : widget.isRecording - ? Icons.pause_rounded - : Icons.mic_rounded, + widget.isRecording ? Icons.pause_rounded : Icons.mic_rounded, size: 48, ), ), @@ -1317,15 +1285,15 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton> } } -class _VoiceInformationField extends StatelessWidget { - const _VoiceInformationField({ - required this.samples, +class _LibraryWaveSurface extends StatelessWidget { + const _LibraryWaveSurface({ + required this.controller, required this.active, required this.progress, required this.liveLevel, }); - final List samples; + final WaveformRecorderController controller; final bool active; final double progress; final double liveLevel; @@ -1342,14 +1310,14 @@ class _VoiceInformationField extends StatelessWidget { BoxShadow( color: const Color( 0xFFFF2D75, - ).withValues(alpha: active ? 0.18 + liveLevel * 0.18 : 0.06), + ).withValues(alpha: active ? 0.20 + liveLevel * 0.18 : 0.06), blurRadius: 110, spreadRadius: 24, ), BoxShadow( color: const Color( 0xFF38F5D3, - ).withValues(alpha: active ? 0.08 + liveLevel * 0.10 : 0.03), + ).withValues(alpha: active ? 0.10 + liveLevel * 0.12 : 0.04), blurRadius: 130, spreadRadius: 14, ), @@ -1357,140 +1325,122 @@ class _VoiceInformationField extends StatelessWidget { ), child: const SizedBox.square(dimension: 210), ), - 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, - ), - ); - }, + 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), + ), ), ], ); } } -class _VoiceInformationPainter extends CustomPainter { - const _VoiceInformationPainter({ - required this.samples, - required this.active, +class _VoiceWaveBar extends StatelessWidget { + const _VoiceWaveBar({ + required this.animation, + required this.amplitude, required this.progress, - required this.liveLevel, }); - final List samples; - final bool active; + final Animation animation; + final Amplitude amplitude; final double progress; - final double liveLevel; @override - 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(); + 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), + )!; - 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 fillOrder = (rows - 1 - row) * columns + column; - final filled = fillOrder < 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, + 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, colors: [ - const Color( - 0xFFE11D48, - ).withValues(alpha: 0.76 + columnSignal * 0.18), - const Color( - 0xFFFF6B8A, - ).withValues(alpha: 0.56 + columnSignal * 0.18), + color.withValues(alpha: 0.42), + color, + const Color(0xFFFF7A90), ], - ).createShader(rect.outerRect) - ..style = PaintingStyle.fill; - canvas.drawRRect(rect.inflate(1.2 + columnSignal * 1.8), glowPaint); - canvas.drawRRect(rect, paint); - } - } - } + ), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.38), + blurRadius: 18, + spreadRadius: 1, + ), + ], + ), + ), + ), + ); } - double _hashUnit(int index) { - final value = math.sin(index * 12.9898 + 78.233) * 43758.5453; - return value - value.floorToDouble(); + 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(); } +} + +class _IdleWaveBars extends StatelessWidget { + const _IdleWaveBars({required this.progress}); + + final double progress; @override - bool shouldRepaint(covariant _VoiceInformationPainter oldDelegate) { - return oldDelegate.samples != samples || - oldDelegate.active != active || - oldDelegate.progress != progress || - oldDelegate.liveLevel != liveLevel; + 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), + ), + ); + }), + ), + ), + ); } }