From 09436217313b45dc87d9bf32871a6b77502a6d84 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 25 Sep 2025 19:40:34 +0530 Subject: [PATCH] refactor: animations --- lib/features/chat/views/chat_page.dart | 17 +- .../widgets/assistant_message_widget.dart | 143 ++++++++++++-- .../chat/widgets/streaming_title_text.dart | 185 ++++++++++++++++++ 3 files changed, 325 insertions(+), 20 deletions(-) create mode 100644 lib/features/chat/widgets/streaming_title_text.dart diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 4ff07f4..5d7c02e 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -19,6 +19,7 @@ import '../../../core/utils/model_icon_utils.dart'; import '../widgets/modern_chat_input.dart'; import '../widgets/user_message_bubble.dart'; import '../widgets/assistant_message_widget.dart' as assistant; +import '../widgets/streaming_title_text.dart'; import '../widgets/file_attachment_widget.dart'; // import '../widgets/voice_input_sheet.dart'; // deprecated: replaced by inline voice input import '../services/voice_input_service.dart'; @@ -1163,11 +1164,13 @@ class _ChatPageState extends ConsumerState { switchOutCurve: Curves.easeInCubic, child: displayConversationTitle != null ? Column( - key: const ValueKey(true), + key: ValueKey( + displayConversationTitle, + ), mainAxisSize: MainAxisSize.min, children: [ - MiddleEllipsisText( - displayConversationTitle, + StreamingTitleText( + title: displayConversationTitle, style: AppTypography.headlineSmallStyle .copyWith( color: context @@ -1177,14 +1180,16 @@ class _ChatPageState extends ConsumerState { fontSize: 18, height: 1.3, ), - textAlign: TextAlign.center, - semanticsLabel: displayConversationTitle, + cursorColor: context + .conduitTheme + .textPrimary + .withValues(alpha: 0.8), ), const SizedBox(height: Spacing.xs), ], ) : const SizedBox.shrink( - key: ValueKey(false), + key: ValueKey('empty-title'), ), ), Transform.translate( diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index f2f35a3..5816a60 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'dart:convert'; @@ -1765,7 +1766,7 @@ class CitationListView extends StatelessWidget { } } -class FollowUpSuggestionBar extends StatelessWidget { +class FollowUpSuggestionBar extends StatefulWidget { const FollowUpSuggestionBar({ super.key, required this.suggestions, @@ -1777,40 +1778,154 @@ class FollowUpSuggestionBar extends StatelessWidget { final ValueChanged onSelected; final bool isBusy; + @override + State createState() => _FollowUpSuggestionBarState(); +} + +class _FollowUpSuggestionBarState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 520), + ); + if (widget.suggestions.isNotEmpty) { + _controller.forward(); + } + } + + @override + void didUpdateWidget(covariant FollowUpSuggestionBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (!listEquals(oldWidget.suggestions, widget.suggestions)) { + if (widget.suggestions.isEmpty) { + _controller.reset(); + } else { + _controller.forward(from: 0); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = context.conduitTheme; - if (suggestions.isEmpty) { + if (widget.suggestions.isEmpty) { return const SizedBox.shrink(); } + final Animation headerAnimation = CurvedAnimation( + parent: _controller, + curve: const Interval(0, 0.35, curve: Curves.easeOutCubic), + ); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Try next', - style: TextStyle( - color: theme.textPrimary, - fontWeight: FontWeight.w600, - fontSize: AppTypography.bodyLarge, + AnimatedBuilder( + animation: headerAnimation, + builder: (context, child) { + return Opacity( + opacity: headerAnimation.value, + child: Transform.translate( + offset: Offset(0, (1 - headerAnimation.value) * 10), + child: child, + ), + ); + }, + child: Text( + 'Try next', + style: TextStyle( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + fontSize: AppTypography.bodyLarge, + ), ), ), const SizedBox(height: Spacing.xs), Wrap( spacing: Spacing.xs, runSpacing: Spacing.xs, - children: suggestions.map((suggestion) { - return FilledButton.tonal( - onPressed: isBusy ? null : () => onSelected(suggestion), - child: Text(suggestion), - ); - }).toList(), + children: [ + for (var i = 0; i < widget.suggestions.length; i++) + _AnimatedSuggestionChip( + controller: _controller, + index: i, + total: widget.suggestions.length, + isBusy: widget.isBusy, + suggestion: widget.suggestions[i], + onSelected: widget.onSelected, + ), + ], ), ], ); } } +class _AnimatedSuggestionChip extends StatelessWidget { + const _AnimatedSuggestionChip({ + required this.controller, + required this.index, + required this.total, + required this.isBusy, + required this.suggestion, + required this.onSelected, + }); + + final AnimationController controller; + final int index; + final int total; + final bool isBusy; + final String suggestion; + final ValueChanged onSelected; + + Interval _intervalForIndex() { + if (total <= 1) { + return const Interval(0.0, 0.8, curve: Curves.easeOutCubic); + } + final double step = 0.6 / total; + final double start = (index * step).clamp(0.0, 0.8); + final double end = (start + 0.4).clamp(0.2, 1.0); + return Interval(start, end, curve: Curves.easeOutCubic); + } + + @override + Widget build(BuildContext context) { + final animation = CurvedAnimation( + parent: controller, + curve: _intervalForIndex(), + ); + + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + final double t = animation.value; + return Opacity( + opacity: t, + child: Transform.translate( + offset: Offset(0, (1 - t) * 12), + child: Transform.scale(scale: 0.95 + (t * 0.05), child: child), + ), + ); + }, + child: FilledButton.tonal( + onPressed: isBusy ? null : () => onSelected(suggestion), + child: Text(suggestion), + ), + ); + } +} + Future _launchUri(String url) async { if (url.isEmpty) return; try { diff --git a/lib/features/chat/widgets/streaming_title_text.dart b/lib/features/chat/widgets/streaming_title_text.dart new file mode 100644 index 0000000..c0d0f09 --- /dev/null +++ b/lib/features/chat/widgets/streaming_title_text.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; + +/// Displays a chat title that reveals characters with a streaming animation +/// whenever the title changes. +class StreamingTitleText extends StatefulWidget { + const StreamingTitleText({ + super.key, + required this.title, + required this.style, + this.cursorColor, + this.cursorWidth = 2, + this.cursorHeight, + this.onAnimationComplete, + }); + + /// The title that should be rendered. When this value changes the widget + /// replays the streaming animation. + final String title; + + /// Text style used for the title. + final TextStyle style; + + /// Optional cursor color while streaming. Defaults to the text color. + final Color? cursorColor; + + /// Width of the animated cursor while the title is streaming. + final double cursorWidth; + + /// Optional cursor height override. When null we use the font size's height. + final double? cursorHeight; + + /// Optional callback fired after the streaming animation finishes. + final VoidCallback? onAnimationComplete; + + @override + State createState() => _StreamingTitleTextState(); +} + +class _StreamingTitleTextState extends State + with TickerProviderStateMixin { + late final AnimationController _revealController; + late final AnimationController _cursorController; + late Animation _cursorOpacity; + String _activeTitle = ''; + + @override + void initState() { + super.initState(); + _activeTitle = widget.title; + + _revealController = + AnimationController(vsync: this, duration: _durationFor(widget.title)) + ..addListener(() { + setState(() {}); + }) + ..addStatusListener((status) { + if (status == AnimationStatus.completed) { + _cursorController.stop(); + widget.onAnimationComplete?.call(); + } + }); + + _cursorController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + _cursorOpacity = TweenSequence([ + TweenSequenceItem( + tween: Tween( + begin: 0.0, + end: 1.0, + ).chain(CurveTween(curve: Curves.easeOutQuart)), + weight: 50, + ), + TweenSequenceItem( + tween: Tween( + begin: 1.0, + end: 0.0, + ).chain(CurveTween(curve: Curves.easeInQuart)), + weight: 50, + ), + ]).animate(_cursorController); + + if (_activeTitle.isNotEmpty) { + // Skip the animation when mounting with an existing title. + _revealController.value = 1.0; + } + } + + @override + void didUpdateWidget(covariant StreamingTitleText oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.title != widget.title) { + _activeTitle = widget.title; + _revealController.duration = _durationFor(widget.title); + if (_activeTitle.isEmpty) { + _revealController.value = 0.0; + _cursorController.stop(); + } else { + _cursorController + ..reset() + ..repeat(reverse: true); + _revealController.forward(from: 0.0); + } + } + } + + @override + void dispose() { + _cursorController.dispose(); + _revealController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_activeTitle.isEmpty) { + return const SizedBox.shrink(); + } + + final characters = _activeTitle.characters; + final int totalGlyphs = characters.length; + final double clampedProgress = _revealController.value.clamp(0.0, 1.0); + int revealedGlyphs = (clampedProgress * totalGlyphs).floor(); + if (revealedGlyphs > totalGlyphs) { + revealedGlyphs = totalGlyphs; + } + final String visibleText = characters.take(revealedGlyphs).toString(); + + final bool isAnimating = + _revealController.isAnimating && revealedGlyphs < totalGlyphs; + + final Color cursorColor = + widget.cursorColor ?? + widget.style.color ?? + Theme.of(context).colorScheme.primary; + + final double cursorHeight = + widget.cursorHeight ?? + (widget.style.fontSize != null + ? widget.style.fontSize! * (widget.style.height ?? 1.1) + : 18.0); + + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Text( + // When the animation completes we fall back to the full string. + revealedGlyphs >= totalGlyphs ? _activeTitle : visibleText, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + textAlign: TextAlign.center, + style: widget.style, + ), + ), + if (isAnimating) + FadeTransition( + opacity: _cursorOpacity, + child: Container( + width: widget.cursorWidth, + height: cursorHeight, + margin: const EdgeInsets.only(left: 2), + decoration: BoxDecoration( + color: cursorColor, + borderRadius: BorderRadius.circular(widget.cursorWidth), + ), + ), + ), + ], + ); + } + + Duration _durationFor(String value) { + if (value.isEmpty) { + return const Duration(milliseconds: 200); + } + final int glyphs = value.characters.length; + final int millis = (glyphs * 28).clamp(360, 1400).toInt(); + return Duration(milliseconds: millis); + } +}