From 66ab3c2e0bb42d00a27f810a09f350a202d28a84 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:53:52 +0700 Subject: [PATCH] Animate voice prompt hints --- .../mapflow/presentation/mapflow_shell.dart | 273 +++++++++++++++--- 1 file changed, 226 insertions(+), 47 deletions(-) diff --git a/lib/features/mapflow/presentation/mapflow_shell.dart b/lib/features/mapflow/presentation/mapflow_shell.dart index 93d8d28..47f2967 100644 --- a/lib/features/mapflow/presentation/mapflow_shell.dart +++ b/lib/features/mapflow/presentation/mapflow_shell.dart @@ -2903,63 +2903,242 @@ class _VoiceStep extends StatelessWidget { final tokens = context.mapflowTokens; final canFinish = canContinue; - return Column( + return Stack( children: [ - if (placeName.trim().isNotEmpty) ...[ - Text( - placeName.trim(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), - ), - const SizedBox(height: 10), - ], - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Wrap( - alignment: WrapAlignment.center, - spacing: 6, - runSpacing: 6, - children: [ - for (final hint in promptHints) - Chip( - label: Text(hint), - side: BorderSide(color: tokens.voiceAccent), - backgroundColor: tokens.voiceAccent, - labelStyle: TextStyle( - color: tokens.onVoiceControl, - fontWeight: FontWeight.w900, - ), - ), + Column( + children: [ + if (placeName.trim().isNotEmpty) ...[ + Text( + placeName.trim(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), + ), + const SizedBox(height: 10), ], - ), - ), - Expanded(child: _VoiceProgressGrid(progress: informationProgress)), - if (!micAllowed) - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Icon( - Icons.mic_off_outlined, - color: tokens.voiceAccentSoft, - size: 22, + Expanded(child: _VoiceProgressGrid(progress: informationProgress)), + if (!micAllowed) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Icon( + Icons.mic_off_outlined, + color: tokens.voiceAccentSoft, + size: 22, + ), + ), + _VoiceRecordButton( + progress: informationProgress, + liveLevel: liveLevel, + isRecording: isRecording, + canFinish: canFinish, + enabled: hasTelegramAuth && !isSubmitting, + onPressed: canFinish ? onNext : onToggleRecording, ), + ], + ), + Positioned.fill( + bottom: 230, + child: _FloatingPromptHints( + hints: promptHints, + active: isRecording || informationProgress < 1, ), - _VoiceRecordButton( - progress: informationProgress, - liveLevel: liveLevel, - isRecording: isRecording, - canFinish: canFinish, - enabled: hasTelegramAuth && !isSubmitting, - onPressed: canFinish ? onNext : onToggleRecording, ), ], ); } } +class _FloatingPromptHints extends StatefulWidget { + const _FloatingPromptHints({required this.hints, required this.active}); + + final List hints; + final bool active; + + @override + State<_FloatingPromptHints> createState() => _FloatingPromptHintsState(); +} + +class _FloatingPromptHintsState extends State<_FloatingPromptHints> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 13500), + ); + _syncAnimation(); + } + + @override + void didUpdateWidget(covariant _FloatingPromptHints oldWidget) { + super.didUpdateWidget(oldWidget); + _syncAnimation(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _syncAnimation() { + if (widget.active && !_controller.isAnimating) { + _controller.repeat(); + } + if (!widget.active && _controller.isAnimating) { + _controller.stop(); + } + } + + @override + Widget build(BuildContext context) { + if (widget.hints.isEmpty) { + return const SizedBox.shrink(); + } + + return IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 260), + opacity: widget.active ? 1 : 0, + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return LayoutBuilder( + builder: (context, constraints) { + return Stack( + fit: StackFit.expand, + children: [ + for (var index = 0; index < widget.hints.length; index++) + _FloatingPromptHint( + label: widget.hints[index], + index: index, + progress: _controller.value, + size: constraints.biggest, + ), + ], + ); + }, + ); + }, + ), + ), + ); + } +} + +class _FloatingPromptHint extends StatelessWidget { + const _FloatingPromptHint({ + required this.label, + required this.index, + required this.progress, + required this.size, + }); + + final String label; + final int index; + final double progress; + final Size size; + + @override + Widget build(BuildContext context) { + final tokens = context.mapflowTokens; + final phase = (progress + index * 0.173) % 1.0; + final eased = Curves.easeInOutCubic.transform(phase); + final visibility = _visibilityForPhase(phase); + final seed = index + 1.0; + final start = _alignment(seed, 0); + final end = _alignment(seed, 1); + final alignment = Alignment( + _lerpDouble(start.x, end.x, eased), + _lerpDouble(start.y, end.y, eased), + ); + final drift = Offset( + math.sin((phase * math.pi * 2) + seed) * size.width * 0.035, + math.cos((phase * math.pi * 2) + seed * 1.9) * size.height * 0.026, + ); + final scale = + 0.76 + + visibility * 0.28 + + math.sin((phase * math.pi * 2) + seed * 2.4) * 0.035; + final rotation = math.sin((phase * math.pi * 2) + seed) * 0.035; + + return Align( + alignment: alignment, + child: Transform.translate( + offset: drift, + child: Transform.rotate( + angle: rotation, + child: Transform.scale( + scale: scale, + child: Opacity( + opacity: visibility, + child: DecoratedBox( + decoration: BoxDecoration( + color: tokens.voiceAccent.withValues(alpha: 0.88), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: tokens.onDark.withValues(alpha: 0.16), + ), + boxShadow: [ + BoxShadow( + color: tokens.voiceAccent.withValues(alpha: 0.30), + blurRadius: 26, + spreadRadius: 1, + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 13, + vertical: 8, + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: tokens.onVoiceControl, + fontWeight: FontWeight.w900, + letterSpacing: 0, + height: 1, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + double _visibilityForPhase(double phase) { + if (phase < 0.16) { + return Curves.easeOutCubic.transform(phase / 0.16); + } + if (phase > 0.70) { + return Curves.easeInCubic.transform((1 - phase) / 0.30); + } + return 1; + } + + Alignment _alignment(double seed, int salt) { + final xSeed = math.sin(seed * 19.73 + salt * 41.11) * 10000; + final ySeed = math.sin(seed * 47.31 + salt * 17.17) * 10000; + final x = ((xSeed - xSeed.floor()) * 1.76 - 0.88).clamp(-0.88, 0.88); + final y = ((ySeed - ySeed.floor()) * 1.58 - 0.79).clamp(-0.79, 0.79); + return Alignment(x, y); + } + + double _lerpDouble(double a, double b, double t) => a + (b - a) * t; +} + class _VoiceProgressGrid extends StatelessWidget { const _VoiceProgressGrid({required this.progress});