Restore voice information grid
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m14s

This commit is contained in:
Ruslan Bakiev
2026-05-13 16:22:18 +07:00
parent d7b419fea6
commit 2366587693

View File

@@ -733,6 +733,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
noiseSuppress: true,
),
);
final _voiceSamples = List<double>.filled(160, 0.0);
Future<List<PlaceRecommendation>>? _nearbyPlacesFuture;
StreamSubscription<Amplitude>? _amplitudeSub;
@@ -790,6 +791,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
_recording = true;
_liveLevel = 0;
_informationUnits = 0;
_voiceSamples.fillRange(0, _voiceSamples.length, 0);
});
}
@@ -814,6 +816,9 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
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<AddExperienceFlow> {
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<double> samples;
final double liveLevel;
final bool canContinue;
final Future<void> 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<void> 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<double> 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,
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,
),
)
: _IdleWaveBars(progress: progress),
),
);
},
),
],
);
}
}
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<double> animation;
final Amplitude amplitude;
final List<double> 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();
}
}
class _IdleWaveBars extends StatelessWidget {
const _IdleWaveBars({required this.progress});
int _cellOrder(int index, int total) => ((index + 11) * 73) % total;
final double progress;
double _hashUnit(int index) {
final value = math.sin(index * 12.9898 + 78.233) * 43758.5453;
return value - value.floorToDouble();
}
@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;
}
}