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/modern_chat_input.dart';
|
||||||
import '../widgets/user_message_bubble.dart';
|
import '../widgets/user_message_bubble.dart';
|
||||||
import '../widgets/assistant_message_widget.dart' as assistant;
|
import '../widgets/assistant_message_widget.dart' as assistant;
|
||||||
|
import '../widgets/streaming_title_text.dart';
|
||||||
import '../widgets/file_attachment_widget.dart';
|
import '../widgets/file_attachment_widget.dart';
|
||||||
// import '../widgets/voice_input_sheet.dart'; // deprecated: replaced by inline voice input
|
// import '../widgets/voice_input_sheet.dart'; // deprecated: replaced by inline voice input
|
||||||
import '../services/voice_input_service.dart';
|
import '../services/voice_input_service.dart';
|
||||||
@@ -1163,11 +1164,13 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
switchOutCurve: Curves.easeInCubic,
|
switchOutCurve: Curves.easeInCubic,
|
||||||
child: displayConversationTitle != null
|
child: displayConversationTitle != null
|
||||||
? Column(
|
? Column(
|
||||||
key: const ValueKey<bool>(true),
|
key: ValueKey<String>(
|
||||||
|
displayConversationTitle,
|
||||||
|
),
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
MiddleEllipsisText(
|
StreamingTitleText(
|
||||||
displayConversationTitle,
|
title: displayConversationTitle,
|
||||||
style: AppTypography.headlineSmallStyle
|
style: AppTypography.headlineSmallStyle
|
||||||
.copyWith(
|
.copyWith(
|
||||||
color: context
|
color: context
|
||||||
@@ -1177,14 +1180,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
height: 1.3,
|
height: 1.3,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
cursorColor: context
|
||||||
semanticsLabel: displayConversationTitle,
|
.conduitTheme
|
||||||
|
.textPrimary
|
||||||
|
.withValues(alpha: 0.8),
|
||||||
),
|
),
|
||||||
const SizedBox(height: Spacing.xs),
|
const SizedBox(height: Spacing.xs),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(
|
: const SizedBox.shrink(
|
||||||
key: ValueKey<bool>(false),
|
key: ValueKey<String>('empty-title'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Transform.translate(
|
Transform.translate(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show listEquals;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
@@ -1765,7 +1766,7 @@ class CitationListView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FollowUpSuggestionBar extends StatelessWidget {
|
class FollowUpSuggestionBar extends StatefulWidget {
|
||||||
const FollowUpSuggestionBar({
|
const FollowUpSuggestionBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.suggestions,
|
required this.suggestions,
|
||||||
@@ -1777,40 +1778,154 @@ class FollowUpSuggestionBar extends StatelessWidget {
|
|||||||
final ValueChanged<String> onSelected;
|
final ValueChanged<String> onSelected;
|
||||||
final bool isBusy;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = context.conduitTheme;
|
final theme = context.conduitTheme;
|
||||||
if (suggestions.isEmpty) {
|
if (widget.suggestions.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final Animation<double> headerAnimation = CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: const Interval(0, 0.35, curve: Curves.easeOutCubic),
|
||||||
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
AnimatedBuilder(
|
||||||
'Try next',
|
animation: headerAnimation,
|
||||||
style: TextStyle(
|
builder: (context, child) {
|
||||||
color: theme.textPrimary,
|
return Opacity(
|
||||||
fontWeight: FontWeight.w600,
|
opacity: headerAnimation.value,
|
||||||
fontSize: AppTypography.bodyLarge,
|
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),
|
const SizedBox(height: Spacing.xs),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: Spacing.xs,
|
spacing: Spacing.xs,
|
||||||
runSpacing: Spacing.xs,
|
runSpacing: Spacing.xs,
|
||||||
children: suggestions.map((suggestion) {
|
children: [
|
||||||
return FilledButton.tonal(
|
for (var i = 0; i < widget.suggestions.length; i++)
|
||||||
onPressed: isBusy ? null : () => onSelected(suggestion),
|
_AnimatedSuggestionChip(
|
||||||
child: Text(suggestion),
|
controller: _controller,
|
||||||
);
|
index: i,
|
||||||
}).toList(),
|
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 {
|
Future<void> _launchUri(String url) async {
|
||||||
if (url.isEmpty) return;
|
if (url.isEmpty) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
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