refactor: animations
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,17 +1778,71 @@ 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(
|
||||
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,
|
||||
@@ -1795,18 +1850,78 @@ class FollowUpSuggestionBar extends StatelessWidget {
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
185
lib/features/chat/widgets/streaming_title_text.dart
Normal file
185
lib/features/chat/widgets/streaming_title_text.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user