refactor: animations

This commit is contained in:
cogwheel0
2025-09-25 19:40:34 +05:30
parent 69e7238d54
commit 0943621731
3 changed files with 325 additions and 20 deletions

View File

@@ -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<ChatPage> {
switchOutCurve: Curves.easeInCubic,
child: displayConversationTitle != null
? Column(
key: const ValueKey<bool>(true),
key: ValueKey<String>(
displayConversationTitle,
),
mainAxisSize: MainAxisSize.min,
children: [
MiddleEllipsisText(
displayConversationTitle,
StreamingTitleText(
title: displayConversationTitle,
style: AppTypography.headlineSmallStyle
.copyWith(
color: context
@@ -1177,14 +1180,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
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<bool>(false),
key: ValueKey<String>('empty-title'),
),
),
Transform.translate(

View File

@@ -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<String> onSelected;
final bool isBusy;
@override
State<FollowUpSuggestionBar> createState() => _FollowUpSuggestionBarState();
}
class _FollowUpSuggestionBarState extends State<FollowUpSuggestionBar>
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<double> 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<String> 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<void> _launchUri(String url) async {
if (url.isEmpty) return;
try {

View File

@@ -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<StreamingTitleText> createState() => _StreamingTitleTextState();
}
class _StreamingTitleTextState extends State<StreamingTitleText>
with TickerProviderStateMixin {
late final AnimationController _revealController;
late final AnimationController _cursorController;
late Animation<double> _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<double>([
TweenSequenceItem(
tween: Tween<double>(
begin: 0.0,
end: 1.0,
).chain(CurveTween(curve: Curves.easeOutQuart)),
weight: 50,
),
TweenSequenceItem(
tween: Tween<double>(
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);
}
}