From f9d6e4fa5b35faeaf6ec3f2961b66669ced07c6a Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Sat, 9 May 2026 17:28:58 +0700 Subject: [PATCH] Polish voice recording screen --- lib/screens/mapflow_shell.dart | 340 ++++++++++++++++++++------------- pubspec.lock | 42 ++-- pubspec.yaml | 3 +- 3 files changed, 227 insertions(+), 158 deletions(-) diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index b61a7ca..308dab5 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -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 { ), }; + 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 { _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 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 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 = []; - final bottom = []; - - 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: 'Закрыть', ), ], diff --git a/pubspec.lock b/pubspec.lock index 1099402..7b84a86 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" cli_config: dependency: transitive description: @@ -368,14 +368,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" latlong2: dependency: "direct main" description: @@ -428,18 +420,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -744,6 +736,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: flutter @@ -817,26 +817,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.16" typed_data: dependency: transitive description: @@ -958,5 +958,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.7 <4.0.0" + dart: ">=3.11.0 <4.0.0" flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 7ca9ef9..644421b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ^3.10.7 + sdk: ^3.11.0 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -41,6 +41,7 @@ dependencies: web: ^1.1.1 geolocator: ^14.0.2 record: ^6.2.0 + siri_wave: ^2.3.1 dev_dependencies: flutter_test: