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:flutter_riverpod/flutter_riverpod.dart';
import 'package:latlong2/latlong.dart' hide Path; import 'package:latlong2/latlong.dart' hide Path;
import 'package:record/record.dart'; import 'package:record/record.dart';
import 'package:siri_wave/siri_wave.dart';
import '../api/mapflow_api.dart'; import '../api/mapflow_api.dart';
import '../auth/telegram_login_button.dart'; import '../auth/telegram_login_button.dart';
@@ -887,8 +888,12 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
), ),
}; };
final isVoiceStep = _step == 1;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF7F3EA), backgroundColor: isVoiceStep
? const Color(0xFF05030B)
: const Color(0xFFF7F3EA),
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
@@ -902,6 +907,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
_StoryProgress( _StoryProgress(
step: _step, step: _step,
total: 3, total: 3,
dark: isVoiceStep,
onClose: () => Navigator.of(context).pop(), onClose: () => Navigator.of(context).pop(),
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
@@ -1091,6 +1097,15 @@ class _VoiceStep extends StatelessWidget {
), ),
const SizedBox(height: 10), 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( Expanded(
child: Center( child: Center(
child: _VoiceWave( child: _VoiceWave(
@@ -1103,7 +1118,11 @@ class _VoiceStep extends StatelessWidget {
if (!micAllowed) if (!micAllowed)
const Padding( const Padding(
padding: EdgeInsets.only(bottom: 10), 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( AnimatedSwitcher(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
@@ -1112,6 +1131,10 @@ class _VoiceStep extends StatelessWidget {
key: const ValueKey('next'), key: const ValueKey('next'),
padding: const EdgeInsets.only(bottom: 14), padding: const EdgeInsets.only(bottom: 14),
child: FilledButton( child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF090613),
),
onPressed: isSubmitting ? null : onNext, onPressed: isSubmitting ? null : onNext,
child: Text(isSubmitting ? 'Отправляем' : 'Далее'), child: Text(isSubmitting ? 'Отправляем' : 'Далее'),
), ),
@@ -1130,7 +1153,7 @@ class _VoiceStep extends StatelessWidget {
} }
} }
class _VoiceRecordButton extends StatelessWidget { class _VoiceRecordButton extends StatefulWidget {
const _VoiceRecordButton({ const _VoiceRecordButton({
required this.time, required this.time,
required this.progress, required this.progress,
@@ -1146,57 +1169,117 @@ class _VoiceRecordButton extends StatelessWidget {
final Future<void> Function() onPressed; final Future<void> Function() onPressed;
@override @override
Widget build(BuildContext context) { State<_VoiceRecordButton> createState() => _VoiceRecordButtonState();
final colorScheme = Theme.of(context).colorScheme; }
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( return SizedBox(
width: 148, width: 164,
height: 148, height: 164,
child: Stack( child: AnimatedBuilder(
alignment: Alignment.center, animation: _pulseController,
children: [ builder: (context, child) {
SizedBox( final pulse = widget.isRecording ? _pulseController.value : 0.0;
width: 148, return Stack(
height: 148, alignment: Alignment.center,
child: CircularProgressIndicator( children: [
value: progress, for (final offset in const [0.0, 0.32])
strokeWidth: 8, Transform.scale(
strokeCap: StrokeCap.round, scale: 1 + ((pulse + offset) % 1) * 0.28,
backgroundColor: colorScheme.primary.withValues(alpha: 0.14), child: Container(
), width: 130,
), height: 130,
SizedBox( decoration: BoxDecoration(
width: 118, shape: BoxShape.circle,
height: 118, border: Border.all(
child: FilledButton( color: const Color(0xFFFF2D75).withValues(
onPressed: enabled ? () => onPressed() : null, alpha: widget.isRecording
style: FilledButton.styleFrom( ? (0.22 * (1 - ((pulse + offset) % 1)))
shape: const CircleBorder(), : 0,
padding: EdgeInsets.zero, ),
), width: 3,
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,
), ),
), ),
], ),
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({ const _VoiceWave({
required this.samples, required this.samples,
required this.active, required this.active,
@@ -1208,105 +1291,84 @@ class _VoiceWave extends StatelessWidget {
final double progress; final double progress;
@override @override
Widget build(BuildContext context) { State<_VoiceWave> createState() => _VoiceWaveState();
return SizedBox(
height: double.infinity,
width: double.infinity,
child: CustomPaint(
painter: _VoiceWavePainter(
samples: samples,
active: active,
progress: progress,
color: Theme.of(context).colorScheme.primary,
),
),
);
}
} }
class _VoiceWavePainter extends CustomPainter { class _VoiceWaveState extends State<_VoiceWave> {
const _VoiceWavePainter({ late final IOS9SiriWaveformController _controller;
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;
@override @override
void paint(Canvas canvas, Size size) { void initState() {
final centerY = size.height / 2; super.initState();
final quietScale = active ? 1.0 : 0.42 + progress * 0.32; _controller = IOS9SiriWaveformController(
final top = <Offset>[]; amplitude: 0.12,
final bottom = <Offset>[]; speed: 0.08,
color1: const Color(0xFFFF2D75),
for (var index = 0; index < samples.length; index++) { color2: const Color(0xFF38F5D3),
final x = samples.length == 1 color3: const Color(0xFF7C5CFF),
? 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),
); );
_syncController();
} }
@override @override
bool shouldRepaint(covariant _VoiceWavePainter oldDelegate) { void didUpdateWidget(covariant _VoiceWave oldWidget) {
return true; 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({ const _StoryProgress({
required this.step, required this.step,
required this.total, required this.total,
required this.dark,
required this.onClose, required this.onClose,
}); });
final int step; final int step;
final int total; final int total;
final bool dark;
final VoidCallback onClose; final VoidCallback onClose;
@override @override
@@ -1352,8 +1416,12 @@ class _StoryProgress extends StatelessWidget {
height: 5, height: 5,
decoration: BoxDecoration( decoration: BoxDecoration(
color: index <= step color: index <= step
? Theme.of(context).colorScheme.primary ? (dark
: const Color(0xFFE0D8CA), ? const Color(0xFFFF2D75)
: Theme.of(context).colorScheme.primary)
: (dark
? Colors.white.withValues(alpha: 0.16)
: const Color(0xFFE0D8CA)),
borderRadius: BorderRadius.circular(99), borderRadius: BorderRadius.circular(99),
), ),
), ),
@@ -1366,7 +1434,7 @@ class _StoryProgress extends StatelessWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
IconButton( IconButton(
onPressed: onClose, onPressed: onClose,
icon: const Icon(Icons.close), icon: Icon(Icons.close, color: dark ? Colors.white : null),
tooltip: 'Закрыть', tooltip: 'Закрыть',
), ),
], ],

View File

@@ -53,10 +53,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
cli_config: cli_config:
dependency: transitive dependency: transitive
description: description:
@@ -368,14 +368,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.1" version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
latlong2: latlong2:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -428,18 +420,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -744,6 +736,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.4" version: "0.1.4"
siri_wave:
dependency: "direct main"
description:
name: siri_wave
sha256: ea815d6627dc297f6be883bb0dd7a579a5f5f9729242d47c10e95850cccf169a
url: "https://pub.dev"
source: hosted
version: "2.3.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -817,26 +817,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test name: test
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.26.3" version: "1.30.0"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.10"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.12" version: "0.6.16"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -958,5 +958,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.10.7 <4.0.0" dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.38.4"

View File

@@ -19,7 +19,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1 version: 1.0.0+1
environment: environment:
sdk: ^3.10.7 sdk: ^3.11.0
# Dependencies specify other packages that your package needs in order to work. # Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions # To automatically upgrade your package dependencies to the latest versions
@@ -41,6 +41,7 @@ dependencies:
web: ^1.1.1 web: ^1.1.1
geolocator: ^14.0.2 geolocator: ^14.0.2
record: ^6.2.0 record: ^6.2.0
siri_wave: ^2.3.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: