Restore wave voice recorder UI
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 10s

This commit is contained in:
Ruslan Bakiev
2026-05-13 17:22:44 +07:00
parent 069dcab479
commit fcc2c26752

View File

@@ -733,11 +733,9 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
noiseSuppress: true, noiseSuppress: true,
), ),
); );
final _voiceSamples = List<double>.filled(160, 0.0);
Future<List<PlaceRecommendation>>? _nearbyPlacesFuture; Future<List<PlaceRecommendation>>? _nearbyPlacesFuture;
StreamSubscription<Amplitude>? _amplitudeSub; StreamSubscription<Amplitude>? _amplitudeSub;
Timer? _visualTimer;
var _step = 0; var _step = 0;
var _informationUnits = 0.0; var _informationUnits = 0.0;
var _recording = false; var _recording = false;
@@ -746,9 +744,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
var _noiseDb = -72.0; var _noiseDb = -72.0;
var _voicePeakDb = -34.0; var _voicePeakDb = -34.0;
var _liveLevel = 0.0; var _liveLevel = 0.0;
var _visualPhase = 0.0;
DateTime? _lastInformationAt; DateTime? _lastInformationAt;
DateTime? _lastAmplitudeAt;
@override @override
void initState() { void initState() {
@@ -758,7 +754,6 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
@override @override
void dispose() { void dispose() {
_amplitudeSub?.cancel(); _amplitudeSub?.cancel();
_visualTimer?.cancel();
_waveController.dispose(); _waveController.dispose();
super.dispose(); super.dispose();
} }
@@ -788,27 +783,21 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
await _waveController.startRecording(); await _waveController.startRecording();
await _amplitudeSub?.cancel(); await _amplitudeSub?.cancel();
_lastInformationAt = DateTime.now(); _lastInformationAt = DateTime.now();
_lastAmplitudeAt = null;
_amplitudeSub = _waveController.amplitudeStream.listen(_handleAmplitude); _amplitudeSub = _waveController.amplitudeStream.listen(_handleAmplitude);
_startVisualTick();
setState(() { setState(() {
_micAllowed = true; _micAllowed = true;
_recording = true; _recording = true;
_liveLevel = 0; _liveLevel = 0;
_informationUnits = 0; _informationUnits = 0;
_voiceSamples.fillRange(0, _voiceSamples.length, 0);
}); });
} }
Future<void> _stopRecording() async { Future<void> _stopRecording() async {
await _amplitudeSub?.cancel(); await _amplitudeSub?.cancel();
_amplitudeSub = null; _amplitudeSub = null;
_visualTimer?.cancel();
_visualTimer = null;
await _waveController.stopRecording(); await _waveController.stopRecording();
_lastInformationAt = null; _lastInformationAt = null;
_lastAmplitudeAt = null;
if (!mounted) { if (!mounted) {
return; return;
} }
@@ -822,16 +811,9 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
final currentDb = amplitude.current; final currentDb = amplitude.current;
final now = DateTime.now(); final now = DateTime.now();
final level = _normalizeDbLevel(currentDb); final level = _normalizeDbLevel(currentDb);
final informationLevel = currentDb > -64 final informationDelta = _consumeInformationDelta(level, now);
? math.max(0.22, level)
: level * 0.35;
final informationDelta = _consumeInformationDelta(informationLevel, now);
_lastAmplitudeAt = now;
setState(() { setState(() {
_voiceSamples
..removeAt(0)
..add(math.max(0.04, level));
_liveLevel = _smoothLevel(_liveLevel, level); _liveLevel = _smoothLevel(_liveLevel, level);
_informationUnits = math.min( _informationUnits = math.min(
_minimumInformationUnits, _minimumInformationUnits,
@@ -843,30 +825,6 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
.setReviewDuration(_waveController.timeElapsed); .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) { double _normalizeDbLevel(double currentDb) {
final db = currentDb.clamp(-160.0, 0.0); final db = currentDb.clamp(-160.0, 0.0);
if (db < _noiseDb) { if (db < _noiseDb) {
@@ -912,7 +870,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
isRecording: _recording, isRecording: _recording,
isSubmitting: _submitting, isSubmitting: _submitting,
micAllowed: _micAllowed, micAllowed: _micAllowed,
samples: _voiceSamples, waveController: _waveController,
liveLevel: _liveLevel, liveLevel: _liveLevel,
canContinue: widget.hasTelegramAuth && informationProgress >= 1, canContinue: widget.hasTelegramAuth && informationProgress >= 1,
onToggleRecording: _toggleRecording, onToggleRecording: _toggleRecording,
@@ -1111,7 +1069,7 @@ class _VoiceStep extends StatelessWidget {
required this.isRecording, required this.isRecording,
required this.isSubmitting, required this.isSubmitting,
required this.micAllowed, required this.micAllowed,
required this.samples, required this.waveController,
required this.liveLevel, required this.liveLevel,
required this.canContinue, required this.canContinue,
required this.onToggleRecording, required this.onToggleRecording,
@@ -1124,7 +1082,7 @@ class _VoiceStep extends StatelessWidget {
final bool isRecording; final bool isRecording;
final bool isSubmitting; final bool isSubmitting;
final bool micAllowed; final bool micAllowed;
final List<double> samples; final WaveformRecorderController waveController;
final double liveLevel; final double liveLevel;
final bool canContinue; final bool canContinue;
final Future<void> Function() onToggleRecording; final Future<void> Function() onToggleRecording;
@@ -1132,7 +1090,7 @@ class _VoiceStep extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final canFinish = canContinue; final showNext = canContinue && !isRecording;
return Column( return Column(
children: [ children: [
@@ -1150,8 +1108,8 @@ class _VoiceStep extends StatelessWidget {
], ],
Expanded( Expanded(
child: Center( child: Center(
child: _VoiceInformationField( child: _LibraryWaveSurface(
samples: samples, controller: waveController,
active: isRecording, active: isRecording,
progress: informationProgress, progress: informationProgress,
liveLevel: liveLevel, liveLevel: liveLevel,
@@ -1167,13 +1125,29 @@ class _VoiceStep extends StatelessWidget {
size: 22, 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( _VoiceRecordButton(
progress: informationProgress, progress: informationProgress,
liveLevel: liveLevel, liveLevel: liveLevel,
isRecording: isRecording, isRecording: isRecording,
canFinish: canFinish,
enabled: hasTelegramAuth && !isSubmitting, enabled: hasTelegramAuth && !isSubmitting,
onPressed: canFinish ? onNext : onToggleRecording, onPressed: onToggleRecording,
), ),
], ],
); );
@@ -1185,7 +1159,6 @@ class _VoiceRecordButton extends StatefulWidget {
required this.progress, required this.progress,
required this.liveLevel, required this.liveLevel,
required this.isRecording, required this.isRecording,
required this.canFinish,
required this.enabled, required this.enabled,
required this.onPressed, required this.onPressed,
}); });
@@ -1193,9 +1166,8 @@ class _VoiceRecordButton extends StatefulWidget {
final double progress; final double progress;
final double liveLevel; final double liveLevel;
final bool isRecording; final bool isRecording;
final bool canFinish;
final bool enabled; final bool enabled;
final VoidCallback onPressed; final Future<void> Function() onPressed;
@override @override
State<_VoiceRecordButton> createState() => _VoiceRecordButtonState(); State<_VoiceRecordButton> createState() => _VoiceRecordButtonState();
@@ -1292,7 +1264,7 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton>
width: 120, width: 120,
height: 120, height: 120,
child: FilledButton( child: FilledButton(
onPressed: widget.enabled ? widget.onPressed : null, onPressed: widget.enabled ? () => widget.onPressed() : null,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: const Color(0xFF090613), foregroundColor: const Color(0xFF090613),
@@ -1303,11 +1275,7 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton>
elevation: 0, elevation: 0,
), ),
child: Icon( child: Icon(
widget.canFinish widget.isRecording ? Icons.pause_rounded : Icons.mic_rounded,
? Icons.check_rounded
: widget.isRecording
? Icons.pause_rounded
: Icons.mic_rounded,
size: 48, size: 48,
), ),
), ),
@@ -1317,15 +1285,15 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton>
} }
} }
class _VoiceInformationField extends StatelessWidget { class _LibraryWaveSurface extends StatelessWidget {
const _VoiceInformationField({ const _LibraryWaveSurface({
required this.samples, required this.controller,
required this.active, required this.active,
required this.progress, required this.progress,
required this.liveLevel, required this.liveLevel,
}); });
final List<double> samples; final WaveformRecorderController controller;
final bool active; final bool active;
final double progress; final double progress;
final double liveLevel; final double liveLevel;
@@ -1342,14 +1310,14 @@ class _VoiceInformationField extends StatelessWidget {
BoxShadow( BoxShadow(
color: const Color( color: const Color(
0xFFFF2D75, 0xFFFF2D75,
).withValues(alpha: active ? 0.18 + liveLevel * 0.18 : 0.06), ).withValues(alpha: active ? 0.20 + liveLevel * 0.18 : 0.06),
blurRadius: 110, blurRadius: 110,
spreadRadius: 24, spreadRadius: 24,
), ),
BoxShadow( BoxShadow(
color: const Color( color: const Color(
0xFF38F5D3, 0xFF38F5D3,
).withValues(alpha: active ? 0.08 + liveLevel * 0.10 : 0.03), ).withValues(alpha: active ? 0.10 + liveLevel * 0.12 : 0.04),
blurRadius: 130, blurRadius: 130,
spreadRadius: 14, spreadRadius: 14,
), ),
@@ -1357,140 +1325,122 @@ class _VoiceInformationField extends StatelessWidget {
), ),
child: const SizedBox.square(dimension: 210), child: const SizedBox.square(dimension: 210),
), ),
LayoutBuilder( Positioned.fill(
builder: (context, constraints) { child: Padding(
return CustomPaint( padding: const EdgeInsets.symmetric(horizontal: 4),
size: Size( child: active
constraints.maxWidth, ? AnimatedWaveList(
constraints.maxHeight.clamp(300.0, 520.0), key: ValueKey(controller.startTime),
), stream: controller.amplitudeStream,
painter: _VoiceInformationPainter( barBuilder: (animation, amplitude) => _VoiceWaveBar(
samples: samples, animation: animation,
active: active, amplitude: amplitude,
progress: progress, progress: progress,
liveLevel: liveLevel,
), ),
); )
}, : _IdleWaveBars(progress: progress),
),
), ),
], ],
); );
} }
} }
class _VoiceInformationPainter extends CustomPainter { class _VoiceWaveBar extends StatelessWidget {
const _VoiceInformationPainter({ const _VoiceWaveBar({
required this.samples, required this.animation,
required this.active, required this.amplitude,
required this.progress, required this.progress,
required this.liveLevel,
}); });
final List<double> samples; final Animation<double> animation;
final bool active; final Amplitude amplitude;
final double progress; final double progress;
final double liveLevel;
@override @override
void paint(Canvas canvas, Size size) { Widget build(BuildContext context) {
const columns = 18; final level = _amplitudeLevel(amplitude.current);
const rows = 12; final height = 14 + level * 210;
const gap = 6.0; final color = Color.lerp(
final cellSize = math Colors.white.withValues(alpha: 0.28),
.min( const Color(0xFFFF2D75),
(size.width - gap * (columns - 1)) / columns, progress.clamp(0.0, 1.0),
(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();
final backgroundPaint = Paint() return SizeTransition(
..color = Colors.white.withValues(alpha: 0.10) sizeFactor: animation,
..style = PaintingStyle.fill; axis: Axis.horizontal,
final borderPaint = Paint() child: Align(
..color = Colors.white.withValues(alpha: 0.05) alignment: Alignment.center,
..style = PaintingStyle.stroke child: Container(
..strokeWidth = 1; width: 5,
final glowPaint = Paint() height: height,
..color = const Color(0xFFFF2D75).withValues(alpha: active ? 0.18 : 0.08) margin: const EdgeInsets.symmetric(horizontal: 2.5),
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 18); decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
for (var row = 0; row < rows; row++) { gradient: LinearGradient(
for (var column = 0; column < columns; column++) { begin: Alignment.bottomCenter,
final cellIndex = row * columns + column; end: Alignment.topCenter,
final sampleIndex = samples.isEmpty colors: [
? 0 color.withValues(alpha: 0.42),
: ((cellIndex / (totalCells - 1)) * (samples.length - 1)).round(); color,
final signal = samples.isEmpty const Color(0xFFFF7A90),
? 0.0 ],
: samples[sampleIndex].clamp(0.0, 1.0); ),
final fillOrder = (rows - 1 - row) * columns + column; boxShadow: [
final filled = fillOrder < activeCells; BoxShadow(
final x = startX + column * (cellSize + gap); color: color.withValues(alpha: 0.38),
final y = startY + row * (cellSize + gap); blurRadius: 18,
final rect = RRect.fromRectAndRadius( spreadRadius: 1,
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) { double _amplitudeLevel(double db) {
final paint = Paint() final normalized = ((db.clamp(-76.0, -6.0) + 76.0) / 70.0).clamp(0.0, 1.0);
..shader = LinearGradient( return math.pow(normalized, 0.62).toDouble();
begin: Alignment.bottomLeft,
end: Alignment.topRight,
colors: [
const Color(
0xFFE11D48,
).withValues(alpha: 0.76 + columnSignal * 0.18),
const Color(
0xFFFF6B8A,
).withValues(alpha: 0.56 + columnSignal * 0.18),
],
).createShader(rect.outerRect)
..style = PaintingStyle.fill;
canvas.drawRRect(rect.inflate(1.2 + columnSignal * 1.8), glowPaint);
canvas.drawRRect(rect, paint);
}
}
} }
} }
double _hashUnit(int index) { class _IdleWaveBars extends StatelessWidget {
final value = math.sin(index * 12.9898 + 78.233) * 43758.5453; const _IdleWaveBars({required this.progress});
return value - value.floorToDouble();
} final double progress;
@override @override
bool shouldRepaint(covariant _VoiceInformationPainter oldDelegate) { Widget build(BuildContext context) {
return oldDelegate.samples != samples || final color = Color.lerp(
oldDelegate.active != active || Colors.white.withValues(alpha: 0.18),
oldDelegate.progress != progress || const Color(0xFFFF2D75),
oldDelegate.liveLevel != liveLevel; 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),
),
);
}),
),
),
);
} }
} }