Gate voice review by information fill
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 1m45s

This commit is contained in:
Ruslan Bakiev
2026-05-09 17:51:42 +07:00
parent 6055a101e8
commit adc935b6cf

View File

@@ -719,7 +719,7 @@ class AddExperienceFlow extends ConsumerStatefulWidget {
}
class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
static const _minimumVoiceSeconds = 30;
static const _minimumInformationUnits = 18.0;
static const _nearbyPlaceRadiusMeters = 200;
final _api = MapflowApi();
@@ -731,9 +731,11 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
StreamSubscription<Uint8List>? _audioStreamSub;
var _step = 0;
var _seconds = 0;
var _informationUnits = 0.0;
var _recording = false;
var _submitting = false;
var _micAllowed = true;
DateTime? _lastAudioChunkAt;
@override
void initState() {
@@ -787,6 +789,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
),
);
_audioStreamSub = stream.listen(_handleAudioChunk);
_lastAudioChunkAt = DateTime.now();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) {
return;
@@ -808,6 +811,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
await _audioStreamSub?.cancel();
_audioStreamSub = null;
await _recorder.stop();
_lastAudioChunkAt = null;
if (!mounted) {
return;
}
@@ -842,6 +846,22 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
return;
}
final now = DateTime.now();
final previousChunkAt = _lastAudioChunkAt ?? now;
_lastAudioChunkAt = now;
final deltaSeconds =
now.difference(previousChunkAt).inMilliseconds.clamp(20, 300) / 1000;
const noiseFloor = 0.08;
final voicedAmount =
peaks
.map(
(peak) =>
((peak - noiseFloor) / (1 - noiseFloor)).clamp(0.0, 1.0),
)
.fold<double>(0, (sum, value) => sum + value) /
peaks.length;
final informationDelta = voicedAmount * deltaSeconds;
if (!mounted) {
return;
}
@@ -849,29 +869,29 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
_waveSamples
..removeRange(0, peaks.length)
..addAll(peaks);
_informationUnits = math.min(
_minimumInformationUnits,
_informationUnits + informationDelta,
);
});
}
String get _time =>
'${(_seconds ~/ 60).toString().padLeft(2, '0')}:'
'${(_seconds % 60).toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
final controller = ref.read(placeControllerProvider.notifier);
final informationProgress = (_informationUnits / _minimumInformationUnits)
.clamp(0.0, 1.0);
final content = switch (_step) {
0 => _IntroStep(onNext: () => setState(() => _step = 1)),
1 => _VoiceStep(
placeName: '',
hasTelegramAuth: widget.hasTelegramAuth,
seconds: _seconds,
minimumSeconds: _minimumVoiceSeconds,
time: _time,
informationProgress: informationProgress,
isRecording: _recording,
isSubmitting: _submitting,
micAllowed: _micAllowed,
samples: _waveSamples,
canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds,
canContinue: widget.hasTelegramAuth && informationProgress >= 1,
onToggleRecording: _toggleRecording,
onNext: () async {
if (_recording) {
@@ -1064,9 +1084,7 @@ class _VoiceStep extends StatelessWidget {
const _VoiceStep({
required this.placeName,
required this.hasTelegramAuth,
required this.seconds,
required this.minimumSeconds,
required this.time,
required this.informationProgress,
required this.isRecording,
required this.isSubmitting,
required this.micAllowed,
@@ -1078,9 +1096,7 @@ class _VoiceStep extends StatelessWidget {
final String placeName;
final bool hasTelegramAuth;
final int seconds;
final int minimumSeconds;
final String time;
final double informationProgress;
final bool isRecording;
final bool isSubmitting;
final bool micAllowed;
@@ -1091,7 +1107,6 @@ class _VoiceStep extends StatelessWidget {
@override
Widget build(BuildContext context) {
final progress = (seconds / minimumSeconds).clamp(0.0, 1.0);
final showNext = canContinue && !isRecording;
return Column(
@@ -1108,21 +1123,12 @@ class _VoiceStep extends StatelessWidget {
),
const SizedBox(height: 10),
],
const SizedBox(height: 8),
Text(
time,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
Expanded(
child: Center(
child: _VoiceWave(
child: _VoiceInformationField(
samples: samples,
active: isRecording,
progress: progress,
progress: informationProgress,
),
),
),
@@ -1153,8 +1159,7 @@ class _VoiceStep extends StatelessWidget {
: const SizedBox.shrink(key: ValueKey('empty-next')),
),
_VoiceRecordButton(
time: time,
progress: progress,
progress: informationProgress,
isRecording: isRecording,
enabled: hasTelegramAuth && !isSubmitting,
onPressed: onToggleRecording,
@@ -1166,14 +1171,12 @@ class _VoiceStep extends StatelessWidget {
class _VoiceRecordButton extends StatefulWidget {
const _VoiceRecordButton({
required this.time,
required this.progress,
required this.isRecording,
required this.enabled,
required this.onPressed,
});
final String time;
final double progress;
final bool isRecording;
final bool enabled;
@@ -1290,8 +1293,8 @@ class _VoiceRecordButtonState extends State<_VoiceRecordButton>
}
}
class _VoiceWave extends StatelessWidget {
const _VoiceWave({
class _VoiceInformationField extends StatelessWidget {
const _VoiceInformationField({
required this.samples,
required this.active,
required this.progress,
@@ -1337,7 +1340,7 @@ class _VoiceWave extends StatelessWidget {
constraints.maxWidth,
constraints.maxHeight.clamp(280.0, 520.0),
),
painter: _VoiceWavePainter(
painter: _VoiceInformationPainter(
samples: samples,
active: active,
progress: progress,
@@ -1350,8 +1353,8 @@ class _VoiceWave extends StatelessWidget {
}
}
class _VoiceWavePainter extends CustomPainter {
const _VoiceWavePainter({
class _VoiceInformationPainter extends CustomPainter {
const _VoiceInformationPainter({
required this.samples,
required this.active,
required this.progress,
@@ -1363,89 +1366,74 @@ class _VoiceWavePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final centerY = size.height / 2;
final widthStep = samples.length <= 1
? size.width
: size.width / (samples.length - 1);
final maxHeight = size.height * 0.46;
final idleHeight = maxHeight * (0.03 + progress * 0.06);
const columns = 16;
const rows = 10;
const gap = 7.0;
final cellWidth = (size.width - gap * (columns - 1)) / columns;
final cellHeight = math.min(24.0, (size.height - gap * (rows - 1)) / rows);
final gridHeight = rows * cellHeight + (rows - 1) * gap;
final startY = (size.height - gridHeight) / 2;
final activeCells = (progress * columns * rows).round();
final fillPath = Path();
final topPath = Path();
final bottomPath = Path();
final denominator = math.max(samples.length - 1, 1);
final backgroundPaint = Paint()
..color = Colors.white.withValues(alpha: 0.055)
..style = PaintingStyle.fill;
final borderPaint = Paint()
..color = Colors.white.withValues(alpha: 0.08)
..style = PaintingStyle.stroke
..strokeWidth = 1;
final glowPaint = Paint()
..color = const Color(0xFFFF2D75).withValues(alpha: active ? 0.22 : 0.10)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 18);
for (var index = 0; index < samples.length; index++) {
final x = index * widthStep;
final envelope = math.sin(index / denominator * math.pi);
final raw = samples[index].clamp(0.0, 1.0);
final height = active
? math.max(raw * envelope * maxHeight, idleHeight)
: idleHeight * envelope;
final top = Offset(x, centerY - height);
final bottom = Offset(x, centerY + height);
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 / (columns * rows - 1)) * (samples.length - 1))
.round();
final signal = samples.isEmpty
? 0.0
: samples[sampleIndex].clamp(0.0, 1.0);
final filled = cellIndex < activeCells;
final x = column * (cellWidth + gap);
final y = startY + row * (cellHeight + gap);
final rect = RRect.fromRectAndRadius(
Rect.fromLTWH(x, y, cellWidth, cellHeight),
const Radius.circular(5),
);
if (index == 0) {
topPath.moveTo(top.dx, top.dy);
bottomPath.moveTo(bottom.dx, bottom.dy);
fillPath.moveTo(top.dx, top.dy);
} else {
topPath.lineTo(top.dx, top.dy);
bottomPath.lineTo(bottom.dx, bottom.dy);
fillPath.lineTo(top.dx, top.dy);
canvas.drawRRect(rect, backgroundPaint);
canvas.drawRRect(rect, borderPaint);
if (filled) {
final warmth = (0.38 + signal * 0.62).clamp(0.0, 1.0);
final paint = Paint()
..shader = LinearGradient(
colors: [
Color.lerp(
const Color(0xFF38F5D3),
const Color(0xFFFF2D75),
warmth,
)!.withValues(alpha: 0.72 + signal * 0.24),
Color.lerp(
const Color(0xFF7C5CFF),
const Color(0xFFFFE4EF),
warmth,
)!.withValues(alpha: 0.42 + signal * 0.22),
],
).createShader(rect.outerRect)
..style = PaintingStyle.fill;
canvas.drawRRect(rect.inflate(signal * 2.2), glowPaint);
canvas.drawRRect(rect, paint);
}
}
}
for (var index = samples.length - 1; index >= 0; index--) {
final x = index * widthStep;
final envelope = math.sin(index / denominator * math.pi);
final raw = samples[index].clamp(0.0, 1.0);
final height = active
? math.max(raw * envelope * maxHeight, idleHeight)
: idleHeight * envelope;
fillPath.lineTo(x, centerY + height);
}
fillPath.close();
final glowPaint = Paint()
..color = const Color(0xFFFF2D75).withValues(alpha: active ? 0.20 : 0.08)
..strokeWidth = 16
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 14);
final strokePaint = Paint()
..shader = const LinearGradient(
colors: [
Color(0xFF38F5D3),
Color(0xFFFFFFFF),
Color(0xFFFF2D75),
Color(0xFF7C5CFF),
],
).createShader(Offset.zero & size)
..strokeWidth = 4.8
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
final fillPaint = Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFF38F5D3).withValues(alpha: active ? 0.12 : 0.04),
const Color(0xFFFF2D75).withValues(alpha: active ? 0.16 : 0.05),
],
).createShader(Offset.zero & size)
..style = PaintingStyle.fill;
canvas.drawPath(fillPath, fillPaint);
canvas.drawPath(topPath, glowPaint);
canvas.drawPath(bottomPath, glowPaint);
canvas.drawPath(topPath, strokePaint);
canvas.drawPath(bottomPath, strokePaint);
}
@override
bool shouldRepaint(covariant _VoiceWavePainter oldDelegate) {
bool shouldRepaint(covariant _VoiceInformationPainter oldDelegate) {
return oldDelegate.samples != samples ||
oldDelegate.active != active ||
oldDelegate.progress != progress;