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,17 +435,20 @@ class _CollapsibleCodeBlockState extends State<_CollapsibleCodeBlock> {
|
|||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AnimatedCrossFade(
|
||||
firstChild: CodeBlock(text: widget.text, textColor: widget.textColor),
|
||||
secondChild: CodeBlock(
|
||||
text: widget.text,
|
||||
textColor: widget.textColor,
|
||||
maxLines: 3,
|
||||
ClipRect(
|
||||
child: AnimatedCrossFade(
|
||||
firstChild: CodeBlock(text: widget.text, textColor: widget.textColor),
|
||||
secondChild: CodeBlock(
|
||||
text: widget.text,
|
||||
textColor: widget.textColor,
|
||||
maxLines: 3,
|
||||
),
|
||||
crossFadeState: _expanded
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
sizeCurve: Curves.easeInOut,
|
||||
),
|
||||
crossFadeState: _expanded
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
|
|
@ -540,24 +543,27 @@ class _CollapsibleThinkingState extends State<_CollapsibleThinking> {
|
|||
],
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: StreamTextWidget(
|
||||
text: widget.text,
|
||||
isStreaming: false,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontStyle: FontStyle.italic,
|
||||
ClipRect(
|
||||
child: AnimatedCrossFade(
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: StreamTextWidget(
|
||||
text: widget.text,
|
||||
isStreaming: false,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
secondChild: const SizedBox.shrink(),
|
||||
crossFadeState: _expanded
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
sizeCurve: Curves.easeInOut,
|
||||
),
|
||||
secondChild: const SizedBox.shrink(),
|
||||
crossFadeState: _expanded
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -148,51 +148,59 @@ class _ApprovalActionCardState extends State<ApprovalActionCard> {
|
|||
const SizedBox(height: 10),
|
||||
|
||||
// Countdown + action buttons
|
||||
Row(
|
||||
Wrap(
|
||||
alignment: WrapAlignment.spaceBetween,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
// Countdown
|
||||
Icon(
|
||||
Icons.timer,
|
||||
size: 14,
|
||||
color: _isExpired ? AppColors.error : AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_countdownLabel,
|
||||
style: TextStyle(
|
||||
color: _isExpired ? AppColors.error : AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.timer,
|
||||
size: 14,
|
||||
color: _isExpired ? AppColors.error : AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_countdownLabel,
|
||||
style: TextStyle(
|
||||
color: _isExpired ? AppColors.error : AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Reject button
|
||||
// Action buttons or status
|
||||
if (!isAlreadyActioned && !_isExpired)
|
||||
OutlinedButton(
|
||||
onPressed: () => _showRejectDialog(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.error,
|
||||
side: const BorderSide(color: AppColors.error),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
minimumSize: Size.zero,
|
||||
),
|
||||
child: const Text('拒绝', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
|
||||
if (!isAlreadyActioned && !_isExpired) const SizedBox(width: 8),
|
||||
|
||||
// Approve button
|
||||
if (!isAlreadyActioned && !_isExpired)
|
||||
FilledButton(
|
||||
onPressed: widget.onApprove,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.success,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
minimumSize: Size.zero,
|
||||
),
|
||||
child: const Text('通过', style: TextStyle(fontSize: 13)),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () => _showRejectDialog(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.error,
|
||||
side: const BorderSide(color: AppColors.error),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
minimumSize: Size.zero,
|
||||
),
|
||||
child: const Text('拒绝', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: widget.onApprove,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.success,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
minimumSize: Size.zero,
|
||||
),
|
||||
child: const Text('通过', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Status label if already actioned
|
||||
|
|
|
|||
|
|
@ -38,33 +38,29 @@ class TimelineEventNode extends StatelessWidget {
|
|||
|
||||
@override
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Left column: vertical line + dot
|
||||
// Left column: dot only (line is drawn by CustomPaint)
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Column(
|
||||
children: [
|
||||
// Line above dot
|
||||
Container(
|
||||
width: 1.5,
|
||||
height: 8,
|
||||
color: isFirst ? Colors.transparent : AppColors.surfaceLight,
|
||||
),
|
||||
// Dot or spinner
|
||||
status == NodeStatus.active
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Center(
|
||||
child: status == NodeStatus.active
|
||||
? _SpinnerDot(color: _dotColor)
|
||||
: _StaticDot(color: _dotColor, icon: icon),
|
||||
// Line below dot
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: 1.5,
|
||||
color: isLast ? Colors.transparent : AppColors.surfaceLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
|
@ -74,6 +70,7 @@ class TimelineEventNode extends StatelessWidget {
|
|||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Label row
|
||||
Padding(
|
||||
|
|
@ -87,6 +84,8 @@ class TimelineEventNode extends StatelessWidget {
|
|||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// 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.
|
||||
class _StaticDot extends StatelessWidget {
|
||||
final Color color;
|
||||
|
|
|
|||
Loading…
Reference in New Issue