Restore wave voice recorder UI
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 10s
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 10s
This commit is contained in:
@@ -733,11 +733,9 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
||||
noiseSuppress: true,
|
||||
),
|
||||
);
|
||||
final _voiceSamples = List<double>.filled(160, 0.0);
|
||||
|
||||
Future<List<PlaceRecommendation>>? _nearbyPlacesFuture;
|
||||
StreamSubscription<Amplitude>? _amplitudeSub;
|
||||
Timer? _visualTimer;
|
||||
var _step = 0;
|
||||
var _informationUnits = 0.0;
|
||||
var _recording = false;
|
||||
@@ -746,9 +744,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
||||
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<AddExperienceFlow> {
|
||||
@override
|
||||
void dispose() {
|
||||
_amplitudeSub?.cancel();
|
||||
_visualTimer?.cancel();
|
||||
_waveController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -788,27 +783,21 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
||||
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<void> _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<AddExperienceFlow> {
|
||||
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<AddExperienceFlow> {
|
||||
.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<AddExperienceFlow> {
|
||||
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<double> samples;
|
||||
final WaveformRecorderController waveController;
|
||||
final double liveLevel;
|
||||
final bool canContinue;
|
||||
final Future<void> 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<void> 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<double> 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<double> samples;
|
||||
final bool active;
|
||||
final Animation<double> 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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user