fix: resolve bottom overflow issues in chat page timeline rendering
Three root causes fixed: 1. TimelineEventNode: Replaced IntrinsicHeight (which forces intrinsic height calculation on unbounded content) with CustomPaint-based _TimelineLinePainter that draws vertical lines based on actual rendered widget size. Also added maxLines/ellipsis to label text and mainAxisSize.min on inner Column. 2. ApprovalActionCard: Changed countdown + action buttons layout from Row with Spacer (which requires infinite width) to Wrap with spacing, preventing horizontal overflow on narrow screens. 3. AnimatedCrossFade in _CollapsibleCodeBlock and _CollapsibleThinking: Wrapped with ClipRect and added sizeCurve: Curves.easeInOut to prevent the outgoing child from extending beyond parent bounds during the cross-fade transition. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
50dbb641a3
commit
89f0f6134d
|
|
@ -435,7 +435,8 @@ class _CollapsibleCodeBlockState extends State<_CollapsibleCodeBlock> {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
AnimatedCrossFade(
|
ClipRect(
|
||||||
|
child: AnimatedCrossFade(
|
||||||
firstChild: CodeBlock(text: widget.text, textColor: widget.textColor),
|
firstChild: CodeBlock(text: widget.text, textColor: widget.textColor),
|
||||||
secondChild: CodeBlock(
|
secondChild: CodeBlock(
|
||||||
text: widget.text,
|
text: widget.text,
|
||||||
|
|
@ -446,6 +447,8 @@ class _CollapsibleCodeBlockState extends State<_CollapsibleCodeBlock> {
|
||||||
? CrossFadeState.showFirst
|
? CrossFadeState.showFirst
|
||||||
: CrossFadeState.showSecond,
|
: CrossFadeState.showSecond,
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
|
sizeCurve: Curves.easeInOut,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => setState(() => _expanded = !_expanded),
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
|
|
@ -540,7 +543,8 @@ class _CollapsibleThinkingState extends State<_CollapsibleThinking> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
AnimatedCrossFade(
|
ClipRect(
|
||||||
|
child: AnimatedCrossFade(
|
||||||
firstChild: Padding(
|
firstChild: Padding(
|
||||||
padding: const EdgeInsets.only(top: 6),
|
padding: const EdgeInsets.only(top: 6),
|
||||||
child: StreamTextWidget(
|
child: StreamTextWidget(
|
||||||
|
|
@ -558,6 +562,8 @@ class _CollapsibleThinkingState extends State<_CollapsibleThinking> {
|
||||||
? CrossFadeState.showFirst
|
? CrossFadeState.showFirst
|
||||||
: CrossFadeState.showSecond,
|
: CrossFadeState.showSecond,
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
|
sizeCurve: Curves.easeInOut,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -148,9 +148,16 @@ class _ApprovalActionCardState extends State<ApprovalActionCard> {
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// Countdown + action buttons
|
// Countdown + action buttons
|
||||||
Row(
|
Wrap(
|
||||||
|
alignment: WrapAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
// Countdown
|
// Countdown
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.timer,
|
Icons.timer,
|
||||||
size: 14,
|
size: 14,
|
||||||
|
|
@ -165,11 +172,14 @@ class _ApprovalActionCardState extends State<ApprovalActionCard> {
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
const Spacer(),
|
// Action buttons or status
|
||||||
|
|
||||||
// Reject button
|
|
||||||
if (!isAlreadyActioned && !_isExpired)
|
if (!isAlreadyActioned && !_isExpired)
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: () => _showRejectDialog(context),
|
onPressed: () => _showRejectDialog(context),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
|
|
@ -180,11 +190,7 @@ class _ApprovalActionCardState extends State<ApprovalActionCard> {
|
||||||
),
|
),
|
||||||
child: const Text('拒绝', style: TextStyle(fontSize: 13)),
|
child: const Text('拒绝', style: TextStyle(fontSize: 13)),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
if (!isAlreadyActioned && !_isExpired) const SizedBox(width: 8),
|
|
||||||
|
|
||||||
// Approve button
|
|
||||||
if (!isAlreadyActioned && !_isExpired)
|
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: widget.onApprove,
|
onPressed: widget.onApprove,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
|
|
@ -194,6 +200,8 @@ class _ApprovalActionCardState extends State<ApprovalActionCard> {
|
||||||
),
|
),
|
||||||
child: const Text('通过', style: TextStyle(fontSize: 13)),
|
child: const Text('通过', style: TextStyle(fontSize: 13)),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
// Status label if already actioned
|
// Status label if already actioned
|
||||||
if (isAlreadyActioned)
|
if (isAlreadyActioned)
|
||||||
|
|
|
||||||
|
|
@ -38,34 +38,30 @@ class TimelineEventNode extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return IntrinsicHeight(
|
// Use CustomPaint for the timeline line to avoid IntrinsicHeight which
|
||||||
|
// causes bottom-overflow on long content.
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _TimelineLinePainter(
|
||||||
|
color: AppColors.surfaceLight,
|
||||||
|
isFirst: isFirst,
|
||||||
|
isLast: isLast,
|
||||||
|
dotOffset: 14, // vertical center of the dot area
|
||||||
|
lineX: 14, // horizontal center of the 28px left column
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Left column: vertical line + dot
|
// Left column: dot only (line is drawn by CustomPaint)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 28,
|
width: 28,
|
||||||
child: Column(
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.only(top: 6),
|
||||||
// Line above dot
|
child: Center(
|
||||||
Container(
|
child: status == NodeStatus.active
|
||||||
width: 1.5,
|
|
||||||
height: 8,
|
|
||||||
color: isFirst ? Colors.transparent : AppColors.surfaceLight,
|
|
||||||
),
|
|
||||||
// Dot or spinner
|
|
||||||
status == NodeStatus.active
|
|
||||||
? _SpinnerDot(color: _dotColor)
|
? _SpinnerDot(color: _dotColor)
|
||||||
: _StaticDot(color: _dotColor, icon: icon),
|
: _StaticDot(color: _dotColor, icon: icon),
|
||||||
// Line below dot
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
width: 1.5,
|
|
||||||
color: isLast ? Colors.transparent : AppColors.surfaceLight,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// Right column: label + content
|
// Right column: label + content
|
||||||
|
|
@ -74,6 +70,7 @@ class TimelineEventNode extends StatelessWidget {
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Label row
|
// Label row
|
||||||
Padding(
|
Padding(
|
||||||
|
|
@ -87,6 +84,8 @@ class TimelineEventNode extends StatelessWidget {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Content
|
// Content
|
||||||
|
|
@ -104,6 +103,51 @@ class TimelineEventNode extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paints the vertical timeline line behind the node content.
|
||||||
|
/// Avoids IntrinsicHeight by painting lines based on the actual widget size.
|
||||||
|
class _TimelineLinePainter extends CustomPainter {
|
||||||
|
final Color color;
|
||||||
|
final bool isFirst;
|
||||||
|
final bool isLast;
|
||||||
|
final double dotOffset;
|
||||||
|
final double lineX;
|
||||||
|
|
||||||
|
_TimelineLinePainter({
|
||||||
|
required this.color,
|
||||||
|
required this.isFirst,
|
||||||
|
required this.isLast,
|
||||||
|
required this.dotOffset,
|
||||||
|
required this.lineX,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = color
|
||||||
|
..strokeWidth = 1.5;
|
||||||
|
|
||||||
|
// Line above the dot
|
||||||
|
if (!isFirst) {
|
||||||
|
canvas.drawLine(Offset(lineX, 0), Offset(lineX, dotOffset), paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line below the dot
|
||||||
|
if (!isLast) {
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(lineX, dotOffset + 10),
|
||||||
|
Offset(lineX, size.height),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_TimelineLinePainter oldDelegate) =>
|
||||||
|
color != oldDelegate.color ||
|
||||||
|
isFirst != oldDelegate.isFirst ||
|
||||||
|
isLast != oldDelegate.isLast;
|
||||||
|
}
|
||||||
|
|
||||||
/// Static colored dot with optional icon for completed/error/idle states.
|
/// Static colored dot with optional icon for completed/error/idle states.
|
||||||
class _StaticDot extends StatelessWidget {
|
class _StaticDot extends StatelessWidget {
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue