Polish voice recording screen
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m27s

This commit is contained in:
Ruslan Bakiev
2026-05-09 17:28:58 +07:00
parent 35ccfe2368
commit f9d6e4fa5b
3 changed files with 227 additions and 158 deletions

View File

@@ -7,6 +7,7 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:latlong2/latlong.dart' hide Path;
import 'package:record/record.dart';
import 'package:siri_wave/siri_wave.dart';
import '../api/mapflow_api.dart';
import '../auth/telegram_login_button.dart';
@@ -887,8 +888,12 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
),
};
final isVoiceStep = _step == 1;
return Scaffold(
backgroundColor: const Color(0xFFF7F3EA),
backgroundColor: isVoiceStep
? const Color(0xFF05030B)
: const Color(0xFFF7F3EA),
body: SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
@@ -902,6 +907,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
_StoryProgress(
step: _step,
total: 3,
dark: isVoiceStep,
onClose: () => Navigator.of(context).pop(),
),
const SizedBox(height: 18),
@@ -1091,6 +1097,15 @@ 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(
@@ -1103,7 +1118,11 @@ class _VoiceStep extends StatelessWidget {
if (!micAllowed)
const Padding(
padding: EdgeInsets.only(bottom: 10),
child: Icon(Icons.mic_off_outlined, size: 22),
child: Icon(
Icons.mic_off_outlined,
color: Color(0xFFFF7A90),
size: 22,
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
@@ -1112,6 +1131,10 @@ class _VoiceStep extends StatelessWidget {
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 ? 'Отправляем' : 'Далее'),
),
@@ -1130,7 +1153,7 @@ class _VoiceStep extends StatelessWidget {
}
}
class _VoiceRecordButton extends StatelessWidget {
class _VoiceRecordButton extends StatefulWidget {
const _VoiceRecordButton({
required this.time,
required this.progress,
@@ -1146,57 +1169,117 @@ class _VoiceRecordButton extends StatelessWidget {
final Future<void> Function() onPressed;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
State<_VoiceRecordButton> createState() => _VoiceRecordButtonState();
}
class _VoiceRecordButtonState extends State<_VoiceRecordButton>
with SingleTickerProviderStateMixin {
late final AnimationController _pulseController;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
);
_syncPulse();
}
@override
void didUpdateWidget(covariant _VoiceRecordButton oldWidget) {
super.didUpdateWidget(oldWidget);
_syncPulse();
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
void _syncPulse() {
if (widget.isRecording && !_pulseController.isAnimating) {
_pulseController.repeat();
}
if (!widget.isRecording && _pulseController.isAnimating) {
_pulseController.stop();
_pulseController.value = 0;
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 148,
height: 148,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 148,
height: 148,
child: CircularProgressIndicator(
value: progress,
strokeWidth: 8,
strokeCap: StrokeCap.round,
backgroundColor: colorScheme.primary.withValues(alpha: 0.14),
),
),
SizedBox(
width: 118,
height: 118,
child: FilledButton(
onPressed: enabled ? () => onPressed() : null,
style: FilledButton.styleFrom(
shape: const CircleBorder(),
padding: EdgeInsets.zero,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(isRecording ? Icons.stop : Icons.mic, size: 42),
const SizedBox(height: 6),
Text(
time,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onPrimary,
fontWeight: FontWeight.w900,
width: 164,
height: 164,
child: AnimatedBuilder(
animation: _pulseController,
builder: (context, child) {
final pulse = widget.isRecording ? _pulseController.value : 0.0;
return Stack(
alignment: Alignment.center,
children: [
for (final offset in const [0.0, 0.32])
Transform.scale(
scale: 1 + ((pulse + offset) % 1) * 0.28,
child: Container(
width: 130,
height: 130,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFFFF2D75).withValues(
alpha: widget.isRecording
? (0.22 * (1 - ((pulse + offset) % 1)))
: 0,
),
width: 3,
),
),
),
],
),
SizedBox(
width: 154,
height: 154,
child: CircularProgressIndicator(
value: widget.progress,
strokeWidth: 7,
strokeCap: StrokeCap.round,
color: const Color(0xFFFF2D75),
backgroundColor: Colors.white.withValues(alpha: 0.12),
),
),
child!,
],
);
},
child: SizedBox(
width: 120,
height: 120,
child: FilledButton(
onPressed: widget.enabled ? () => widget.onPressed() : null,
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF090613),
disabledBackgroundColor: Colors.white.withValues(alpha: 0.28),
disabledForegroundColor: Colors.white.withValues(alpha: 0.52),
shape: const CircleBorder(),
padding: EdgeInsets.zero,
elevation: 0,
),
child: Icon(
widget.isRecording ? Icons.pause_rounded : Icons.mic_rounded,
size: 48,
),
),
],
),
),
);
}
}
class _VoiceWave extends StatelessWidget {
class _VoiceWave extends StatefulWidget {
const _VoiceWave({
required this.samples,
required this.active,
@@ -1208,105 +1291,84 @@ class _VoiceWave extends StatelessWidget {
final double progress;
@override
Widget build(BuildContext context) {
return SizedBox(
height: double.infinity,
width: double.infinity,
child: CustomPaint(
painter: _VoiceWavePainter(
samples: samples,
active: active,
progress: progress,
color: Theme.of(context).colorScheme.primary,
),
),
);
}
State<_VoiceWave> createState() => _VoiceWaveState();
}
class _VoiceWavePainter extends CustomPainter {
const _VoiceWavePainter({
required this.samples,
required this.active,
required this.progress,
required this.color,
});
final List<double> samples;
final bool active;
final double progress;
final Color color;
class _VoiceWaveState extends State<_VoiceWave> {
late final IOS9SiriWaveformController _controller;
@override
void paint(Canvas canvas, Size size) {
final centerY = size.height / 2;
final quietScale = active ? 1.0 : 0.42 + progress * 0.32;
final top = <Offset>[];
final bottom = <Offset>[];
for (var index = 0; index < samples.length; index++) {
final x = samples.length == 1
? 0.0
: index / (samples.length - 1) * size.width;
final envelope = math.sin(index / (samples.length - 1) * math.pi);
final amplitude =
samples[index] * quietScale * envelope * size.height * 0.46;
final yTop = centerY - amplitude;
final yBottom = centerY + amplitude;
top.add(Offset(x, yTop));
bottom.add(Offset(x, yBottom));
}
final topPath = Path()..addPolygon(top, false);
final bottomPath = Path()..addPolygon(bottom, false);
final shape = Path()
..addPolygon([...top, ...bottom.reversed], true)
..close();
final glow = Paint()
..color = color.withValues(alpha: active ? 0.18 : 0.08)
..strokeWidth = 18
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10);
final stroke = Paint()
..shader = LinearGradient(
colors: [
color.withValues(alpha: active ? 0.35 : 0.16),
color.withValues(alpha: active ? 0.95 : 0.42),
const Color(0xFFE11D48).withValues(alpha: active ? 0.86 : 0.34),
],
).createShader(Offset.zero & size)
..strokeWidth = 5
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final fill = Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color.withValues(alpha: active ? 0.20 : 0.08),
const Color(0xFFE11D48).withValues(alpha: active ? 0.14 : 0.05),
],
).createShader(Offset.zero & size)
..style = PaintingStyle.fill;
canvas.drawPath(shape, fill);
canvas.drawPath(topPath, glow);
canvas.drawPath(bottomPath, glow);
canvas.drawPath(topPath, stroke);
canvas.drawPath(bottomPath, stroke);
canvas.drawCircle(
Offset(size.width / 2, centerY),
active ? 3.4 : 2.2,
Paint()..color = color.withValues(alpha: active ? 0.82 : 0.36),
void initState() {
super.initState();
_controller = IOS9SiriWaveformController(
amplitude: 0.12,
speed: 0.08,
color1: const Color(0xFFFF2D75),
color2: const Color(0xFF38F5D3),
color3: const Color(0xFF7C5CFF),
);
_syncController();
}
@override
bool shouldRepaint(covariant _VoiceWavePainter oldDelegate) {
return true;
void didUpdateWidget(covariant _VoiceWave oldWidget) {
super.didUpdateWidget(oldWidget);
_syncController();
}
void _syncController() {
final lastLevel = widget.samples.isEmpty ? 0.04 : widget.samples.last;
_controller.amplitude = widget.active
? (0.18 + lastLevel * 0.78).clamp(0.0, 1.0)
: (0.10 + widget.progress * 0.12).clamp(0.0, 1.0);
_controller.speed = widget.active
? (0.10 + lastLevel * 0.36).clamp(0.0, 1.0)
: 0.035;
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
Container(
width: 220,
height: 220,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF0C0718),
boxShadow: [
BoxShadow(
color: const Color(
0xFFFF2D75,
).withValues(alpha: widget.active ? 0.18 : 0.08),
blurRadius: 90,
spreadRadius: 18,
),
BoxShadow(
color: const Color(
0xFF38F5D3,
).withValues(alpha: widget.active ? 0.12 : 0.05),
blurRadius: 120,
spreadRadius: 14,
),
],
),
),
LayoutBuilder(
builder: (context, constraints) {
return SiriWaveform.ios9(
controller: _controller,
options: IOS9SiriWaveformOptions(
width: constraints.maxWidth,
height: constraints.maxHeight.clamp(280.0, 520.0),
showSupportBar: false,
),
);
},
),
],
);
}
}
@@ -1331,11 +1393,13 @@ class _StoryProgress extends StatelessWidget {
const _StoryProgress({
required this.step,
required this.total,
required this.dark,
required this.onClose,
});
final int step;
final int total;
final bool dark;
final VoidCallback onClose;
@override
@@ -1352,8 +1416,12 @@ class _StoryProgress extends StatelessWidget {
height: 5,
decoration: BoxDecoration(
color: index <= step
? Theme.of(context).colorScheme.primary
: const Color(0xFFE0D8CA),
? (dark
? const Color(0xFFFF2D75)
: Theme.of(context).colorScheme.primary)
: (dark
? Colors.white.withValues(alpha: 0.16)
: const Color(0xFFE0D8CA)),
borderRadius: BorderRadius.circular(99),
),
),
@@ -1366,7 +1434,7 @@ class _StoryProgress extends StatelessWidget {
const SizedBox(width: 10),
IconButton(
onPressed: onClose,
icon: const Icon(Icons.close),
icon: Icon(Icons.close, color: dark ? Colors.white : null),
tooltip: 'Закрыть',
),
],