Gate voice review by information fill
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 1m45s
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 1m45s
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user