Animate voice prompt hints
Build and deploy Flutter Web / build (push) Successful in 5m7s

This commit is contained in:
Ruslan Bakiev
2026-06-24 11:53:52 +07:00
parent 764e93df59
commit 66ab3c2e0b
@@ -2903,7 +2903,9 @@ class _VoiceStep extends StatelessWidget {
final tokens = context.mapflowTokens; final tokens = context.mapflowTokens;
final canFinish = canContinue; final canFinish = canContinue;
return Column( return Stack(
children: [
Column(
children: [ children: [
if (placeName.trim().isNotEmpty) ...[ if (placeName.trim().isNotEmpty) ...[
Text( Text(
@@ -2917,26 +2919,6 @@ class _VoiceStep extends StatelessWidget {
), ),
const SizedBox(height: 10), 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,
),
),
],
),
),
Expanded(child: _VoiceProgressGrid(progress: informationProgress)), Expanded(child: _VoiceProgressGrid(progress: informationProgress)),
if (!micAllowed) if (!micAllowed)
Padding( Padding(
@@ -2956,10 +2938,207 @@ class _VoiceStep extends StatelessWidget {
onPressed: canFinish ? onNext : onToggleRecording, onPressed: canFinish ? onNext : onToggleRecording,
), ),
], ],
),
Positioned.fill(
bottom: 230,
child: _FloatingPromptHints(
hints: promptHints,
active: isRecording || informationProgress < 1,
),
),
],
); );
} }
} }
class _FloatingPromptHints extends StatefulWidget {
const _FloatingPromptHints({required this.hints, required this.active});
final List<String> 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 { class _VoiceProgressGrid extends StatelessWidget {
const _VoiceProgressGrid({required this.progress}); const _VoiceProgressGrid({required this.progress});