+8
-2
lib/src/features/feeds/presentation/screens/widgets/feed_post_card.dart
+8
-2
lib/src/features/feeds/presentation/screens/widgets/feed_post_card.dart
···
20
20
/// Adapts [FeedPost] data to display a rich post card with
21
21
/// author info, post text, embeds, and action counts.
22
22
class FeedPostCard extends ConsumerWidget {
23
-
const FeedPostCard({required this.item, this.onTap, super.key});
23
+
const FeedPostCard({
24
+
required this.item,
25
+
this.onTap,
26
+
this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
27
+
super.key,
28
+
});
24
29
25
30
final FeedPost item;
26
31
final VoidCallback? onTap;
32
+
final EdgeInsetsGeometry margin;
27
33
28
34
@override
29
35
Widget build(BuildContext context, WidgetRef ref) {
···
159
165
160
166
return Card(
161
167
clipBehavior: Clip.antiAlias,
162
-
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
168
+
margin: margin,
163
169
elevation: 2,
164
170
child: InkWell(
165
171
onTap:
+69
-49
lib/src/features/feeds/presentation/widgets/post/post_actions_row.dart
+69
-49
lib/src/features/feeds/presentation/widgets/post/post_actions_row.dart
···
41
41
final isLiked = viewerLikeUri != null;
42
42
final isReposted = viewerRepostUri != null;
43
43
44
-
return Row(
45
-
mainAxisAlignment: MainAxisAlignment.spaceBetween,
46
-
children: [
47
-
_ActionItem(
48
-
icon: Icons.chat_bubble_outline,
49
-
count: replyCount,
50
-
onTap: onReply,
51
-
tooltip: 'Reply',
52
-
),
53
-
_ActionItem(
54
-
icon: Icons.repeat,
55
-
count: repostCount,
56
-
isActive: isReposted,
57
-
activeColor: Colors.green,
58
-
onTap: onRepost,
59
-
tooltip: isReposted ? 'Unrepost' : 'Repost',
60
-
),
61
-
_ActionItem(
62
-
icon: isLiked ? Icons.favorite : Icons.favorite_border,
63
-
count: likeCount,
64
-
isActive: isLiked,
65
-
activeColor: Colors.red,
66
-
onTap: onLike,
67
-
tooltip: isLiked ? 'Unlike' : 'Like',
68
-
),
69
-
_ActionItem(
70
-
icon: viewerBookmarked ? Icons.bookmark : Icons.bookmark_border,
71
-
count: 0,
72
-
isActive: viewerBookmarked,
73
-
activeColor: Colors.amber,
74
-
onTap: onBookmark,
75
-
tooltip: viewerBookmarked ? 'Remove Bookmark' : 'Bookmark',
76
-
),
77
-
IconButton(
78
-
onPressed: onMore,
79
-
icon: const Icon(Icons.more_horiz, size: 18),
80
-
color: AppColors.textSecondary,
81
-
tooltip: 'More',
82
-
padding: EdgeInsets.zero,
83
-
constraints: const BoxConstraints(),
84
-
),
85
-
],
44
+
return LayoutBuilder(
45
+
builder: (context, constraints) {
46
+
final showCounts = constraints.maxWidth > 240;
47
+
final itemPadding = constraints.maxWidth > 200
48
+
? 8.0
49
+
: constraints.maxWidth > 160
50
+
? 4.0
51
+
: 2.0;
52
+
53
+
return Row(
54
+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
55
+
children: [
56
+
_ActionItem(
57
+
icon: Icons.chat_bubble_outline,
58
+
count: showCounts ? replyCount : 0,
59
+
onTap: onReply,
60
+
tooltip: 'Reply',
61
+
padding: itemPadding,
62
+
),
63
+
_ActionItem(
64
+
icon: Icons.repeat,
65
+
count: showCounts ? repostCount : 0,
66
+
isActive: isReposted,
67
+
activeColor: Colors.green,
68
+
onTap: onRepost,
69
+
tooltip: isReposted ? 'Unrepost' : 'Repost',
70
+
padding: itemPadding,
71
+
),
72
+
_ActionItem(
73
+
icon: isLiked ? Icons.favorite : Icons.favorite_border,
74
+
count: showCounts ? likeCount : 0,
75
+
isActive: isLiked,
76
+
activeColor: Colors.red,
77
+
onTap: onLike,
78
+
tooltip: isLiked ? 'Unlike' : 'Like',
79
+
padding: itemPadding,
80
+
),
81
+
_ActionItem(
82
+
icon: viewerBookmarked ? Icons.bookmark : Icons.bookmark_border,
83
+
count: 0,
84
+
isActive: viewerBookmarked,
85
+
activeColor: Colors.amber,
86
+
onTap: onBookmark,
87
+
tooltip: viewerBookmarked ? 'Remove Bookmark' : 'Bookmark',
88
+
padding: itemPadding,
89
+
),
90
+
IconButton(
91
+
onPressed: onMore,
92
+
icon: const Icon(Icons.more_horiz, size: 18),
93
+
color: AppColors.textSecondary,
94
+
tooltip: 'More',
95
+
padding: EdgeInsets.zero,
96
+
constraints: const BoxConstraints(),
97
+
),
98
+
],
99
+
);
100
+
},
86
101
);
87
102
}
88
103
}
···
95
110
this.activeColor,
96
111
this.onTap,
97
112
this.tooltip,
113
+
this.padding = 8.0,
98
114
});
99
115
100
116
final IconData icon;
···
103
119
final Color? activeColor;
104
120
final VoidCallback? onTap;
105
121
final String? tooltip;
122
+
final double padding;
106
123
107
124
@override
108
125
State<_ActionItem> createState() => _ActionItemState();
···
147
164
onTap: _handleTap,
148
165
borderRadius: BorderRadius.circular(20),
149
166
child: Padding(
150
-
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
167
+
padding: EdgeInsets.symmetric(horizontal: widget.padding, vertical: 4),
151
168
child: Row(
152
169
mainAxisSize: MainAxisSize.min,
153
170
children: [
···
157
174
),
158
175
if (widget.count > 0) ...[
159
176
const SizedBox(width: 4),
160
-
Text(
161
-
_formatCount(widget.count),
162
-
style: TextStyle(
163
-
color: color,
164
-
fontSize: 13,
165
-
fontWeight: widget.isActive ? FontWeight.bold : FontWeight.normal,
177
+
ConstraintsTransformBox(
178
+
constraintsTransform: ConstraintsTransformBox.unconstrained,
179
+
child: Text(
180
+
_formatCount(widget.count),
181
+
style: TextStyle(
182
+
color: color,
183
+
fontSize: 13,
184
+
fontWeight: widget.isActive ? FontWeight.bold : FontWeight.normal,
185
+
),
166
186
),
167
187
),
168
188
],
+1
-14
lib/src/features/thread/domain/thread_layout_calculator.dart
+1
-14
lib/src/features/thread/domain/thread_layout_calculator.dart
···
11
11
static const double baseIndent = 0.0;
12
12
13
13
/// Additional indentation per nesting level
14
-
static const double indentPerLevel = 32.0;
14
+
static const double indentPerLevel = 12.0;
15
15
16
16
/// Maximum depth before flattening
17
17
static const int maxDepth = 5;
18
-
19
-
/// Avatar radius in pixels
20
-
static const double avatarRadius = 20.0;
21
-
22
-
/// Offset from left edge to avatar center
23
-
static const double avatarCenterOffset = 20.0;
24
18
25
19
/// Calculate left padding for a post at given depth.
26
20
///
···
28
22
static double calculateIndent(int depth) {
29
23
final effectiveDepth = depth.clamp(0, maxDepth);
30
24
return baseIndent + (effectiveDepth * indentPerLevel);
31
-
}
32
-
33
-
/// Calculate connector line position for a given depth.
34
-
///
35
-
/// Positions the connector at the avatar center, accounting for indentation.
36
-
static double calculateConnectorLeft(int depth) {
37
-
return calculateIndent(depth) + avatarCenterOffset;
38
25
}
39
26
40
27
/// Determine if depth should be flattened.
+18
-36
lib/src/features/thread/presentation/thread_screen.dart
+18
-36
lib/src/features/thread/presentation/thread_screen.dart
···
8
8
import 'package:lazurite/src/features/thread/application/thread_notifier.dart';
9
9
import 'package:lazurite/src/features/thread/application/thread_providers.dart';
10
10
import 'package:lazurite/src/features/thread/domain/thread.dart';
11
-
import 'package:lazurite/src/features/thread/domain/thread_layout_calculator.dart';
12
11
import 'package:lazurite/src/features/thread/presentation/widgets/blocked_post_card.dart';
13
-
import 'package:lazurite/src/features/thread/presentation/widgets/collapsed_reply_preview.dart';
14
12
import 'package:lazurite/src/features/thread/presentation/widgets/not_found_post_card.dart';
15
13
import 'package:lazurite/src/features/thread/presentation/widgets/thread_line_connector.dart';
16
14
import 'package:lazurite/src/features/thread/presentation/widgets/thread_reply_item.dart';
···
130
128
SliverList(
131
129
delegate: SliverChildBuilderDelegate((context, index) {
132
130
final reply = sortedReplies[index];
133
-
return _buildReplyTree(
134
-
reply,
135
-
depth: 1,
136
-
isLastSibling: index == sortedReplies.length - 1,
137
-
);
131
+
return _buildReplyTree(reply, depth: 1);
138
132
}, childCount: sortedReplies.length),
139
133
),
140
134
],
···
229
223
230
224
return Container(
231
225
decoration: BoxDecoration(
232
-
color: colorScheme.primaryContainer.withValues(alpha: 0.1),
233
-
border: Border(
234
-
left: BorderSide(color: colorScheme.primary, width: 3),
235
-
bottom: BorderSide(color: theme.dividerColor, width: 4),
236
-
),
226
+
color: colorScheme.surfaceContainerLow,
227
+
borderRadius: BorderRadius.circular(12),
228
+
border: Border.all(color: colorScheme.primary.withValues(alpha: 0.45), width: 1.5),
237
229
),
238
230
child: Column(
239
231
crossAxisAlignment: CrossAxisAlignment.start,
···
299
291
}
300
292
301
293
/// Recursively builds reply tree with nesting and collapse support
302
-
Widget _buildReplyTree(ThreadViewPost post, {required int depth, bool isLastSibling = false}) {
294
+
Widget _buildReplyTree(ThreadViewPost post, {required int depth}) {
303
295
final isCollapsed = ref.watch(
304
296
threadCollapseStateProvider.select((state) => state[post.post.uri] ?? false),
305
297
);
···
314
306
onToggleCollapse: () {
315
307
ref.read(threadCollapseStateProvider.notifier).toggle(post.post.uri);
316
308
},
317
-
isLastSibling: isLastSibling,
318
-
hasMoreSiblings: !isLastSibling,
319
309
),
320
-
321
-
if (!isCollapsed && post.replies.isNotEmpty)
322
-
...post.replies.asMap().entries.map((entry) {
323
-
final index = entry.key;
324
-
final reply = entry.value;
325
-
return _buildReplyTree(
326
-
reply,
327
-
depth: depth + 1,
328
-
isLastSibling: index == post.replies.length - 1,
329
-
);
330
-
}),
331
-
332
-
if (isCollapsed && post.replies.isNotEmpty)
333
-
CollapsedReplyPreview(
334
-
firstReply: post.replies.first,
335
-
totalCount: ThreadLayoutCalculator.countAllReplies(post),
336
-
onExpand: () {
337
-
ref.read(threadCollapseStateProvider.notifier).toggle(post.post.uri);
338
-
},
339
-
indent: ThreadLayoutCalculator.calculateIndent(depth),
340
-
),
310
+
AnimatedSize(
311
+
duration: const Duration(milliseconds: 250),
312
+
curve: Curves.easeInOut,
313
+
alignment: Alignment.topCenter,
314
+
child: (!isCollapsed && post.replies.isNotEmpty)
315
+
? Column(
316
+
crossAxisAlignment: CrossAxisAlignment.start,
317
+
children: post.replies
318
+
.map((reply) => _buildReplyTree(reply, depth: depth + 1))
319
+
.toList(),
320
+
)
321
+
: const SizedBox.shrink(),
322
+
),
341
323
],
342
324
);
343
325
}
+14
-47
lib/src/features/thread/presentation/widgets/collapse_toggle.dart
+14
-47
lib/src/features/thread/presentation/widgets/collapse_toggle.dart
···
1
+
import 'package:flutter/cupertino.dart';
1
2
import 'package:flutter/material.dart';
2
3
3
-
/// A collapsible toggle button with triangle icon and reply count badge.
4
+
/// A collapsible toggle with Cupertino chevron-circle icons.
4
5
///
5
-
/// Displays a triangle icon that rotates 90 degrees when collapsed (pointing right)
6
-
/// vs expanded (pointing down). Shows a reply count badge when provided.
6
+
/// Uses `chevron_right_circle` when collapsed and `chevron_down_circle`
7
+
/// when expanded, matching the system thread expansion affordance.
7
8
class CollapseToggle extends StatelessWidget {
8
-
const CollapseToggle({
9
-
required this.isCollapsed,
10
-
required this.onTap,
11
-
this.replyCount = 0,
12
-
this.showCount = false,
13
-
super.key,
14
-
});
9
+
const CollapseToggle({required this.isCollapsed, required this.onTap, super.key});
15
10
16
11
/// Whether the associated thread is currently collapsed
17
12
final bool isCollapsed;
···
19
14
/// Callback when toggle is tapped
20
15
final VoidCallback onTap;
21
16
22
-
/// Number of replies (for badge display)
23
-
final int replyCount;
24
-
25
-
/// Whether to show the reply count badge
26
-
final bool showCount;
27
-
28
17
@override
29
18
Widget build(BuildContext context) {
30
19
final theme = Theme.of(context);
31
20
final colorScheme = theme.colorScheme;
32
21
22
+
final icon = isCollapsed
23
+
? CupertinoIcons.chevron_right_circle
24
+
: CupertinoIcons.chevron_down_circle;
25
+
33
26
return InkWell(
34
27
onTap: onTap,
35
-
borderRadius: BorderRadius.circular(4),
36
-
child: Padding(
37
-
padding: const EdgeInsets.all(4),
38
-
child: Row(
39
-
mainAxisSize: MainAxisSize.min,
40
-
children: [
41
-
AnimatedRotation(
42
-
turns: isCollapsed ? -0.25 : 0, // -90° when collapsed, 0° when expanded
43
-
duration: const Duration(milliseconds: 200),
44
-
child: Icon(Icons.play_arrow, size: 16, color: colorScheme.onSurfaceVariant),
45
-
),
46
-
if (showCount && replyCount > 0) ...[
47
-
const SizedBox(width: 4),
48
-
Container(
49
-
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
50
-
decoration: BoxDecoration(
51
-
color: colorScheme.secondaryContainer,
52
-
borderRadius: BorderRadius.circular(10),
53
-
),
54
-
child: Text(
55
-
replyCount.toString(),
56
-
style: theme.textTheme.labelSmall?.copyWith(
57
-
color: colorScheme.onSecondaryContainer,
58
-
fontSize: 10,
59
-
fontWeight: FontWeight.w600,
60
-
),
61
-
),
62
-
),
63
-
],
64
-
],
65
-
),
28
+
borderRadius: BorderRadius.circular(18),
29
+
child: SizedBox(
30
+
width: 32,
31
+
height: 32,
32
+
child: Center(child: Icon(icon, size: 20, color: colorScheme.onSurfaceVariant)),
66
33
),
67
34
);
68
35
}
-109
lib/src/features/thread/presentation/widgets/collapsed_reply_preview.dart
-109
lib/src/features/thread/presentation/widgets/collapsed_reply_preview.dart
···
1
-
import 'dart:convert';
2
-
3
-
import 'package:flutter/material.dart';
4
-
import 'package:lazurite/src/features/thread/domain/thread.dart';
5
-
6
-
/// Shows a preview of collapsed replies.
7
-
///
8
-
/// Displays the first reply's text (truncated) and total reply count,
9
-
/// similar to Reddit's collapsed thread preview.
10
-
class CollapsedReplyPreview extends StatelessWidget {
11
-
const CollapsedReplyPreview({
12
-
required this.firstReply,
13
-
required this.totalCount,
14
-
required this.onExpand,
15
-
required this.indent,
16
-
super.key,
17
-
});
18
-
19
-
/// First reply to show preview from
20
-
final ThreadViewPost firstReply;
21
-
22
-
/// Total number of all nested replies
23
-
final int totalCount;
24
-
25
-
/// Callback when preview is tapped to expand
26
-
final VoidCallback onExpand;
27
-
28
-
/// Left indent for alignment with parent
29
-
final double indent;
30
-
31
-
@override
32
-
Widget build(BuildContext context) {
33
-
final theme = Theme.of(context);
34
-
final colorScheme = theme.colorScheme;
35
-
36
-
final previewText = _extractPreviewText(firstReply, maxLength: 100);
37
-
final authorHandle = firstReply.post.author.handle;
38
-
39
-
return InkWell(
40
-
onTap: onExpand,
41
-
child: Container(
42
-
margin: EdgeInsets.only(left: indent + 24, right: 8, top: 4, bottom: 8),
43
-
padding: const EdgeInsets.all(12),
44
-
decoration: BoxDecoration(
45
-
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
46
-
borderRadius: BorderRadius.circular(8),
47
-
border: Border.all(color: colorScheme.outlineVariant, width: 1),
48
-
),
49
-
child: Column(
50
-
crossAxisAlignment: CrossAxisAlignment.start,
51
-
children: [
52
-
Row(
53
-
children: [
54
-
Icon(Icons.unfold_more, size: 16, color: colorScheme.onSurfaceVariant),
55
-
const SizedBox(width: 8),
56
-
Expanded(
57
-
child: Text(
58
-
'@$authorHandle: $previewText',
59
-
style: theme.textTheme.bodySmall?.copyWith(
60
-
color: colorScheme.onSurfaceVariant,
61
-
),
62
-
maxLines: 2,
63
-
overflow: TextOverflow.ellipsis,
64
-
),
65
-
),
66
-
],
67
-
),
68
-
69
-
const SizedBox(height: 4),
70
-
Text(
71
-
_formatReplyCount(totalCount),
72
-
style: theme.textTheme.labelSmall?.copyWith(
73
-
color: colorScheme.primary,
74
-
fontWeight: FontWeight.w600,
75
-
),
76
-
),
77
-
],
78
-
),
79
-
),
80
-
);
81
-
}
82
-
83
-
String _extractPreviewText(ThreadViewPost post, {int maxLength = 100}) {
84
-
if (post.isBlocked) return 'Blocked post';
85
-
if (post.isNotFound) return 'Post not found';
86
-
87
-
try {
88
-
final record = jsonDecode(post.post.record['text'] as String? ?? '{}');
89
-
if (record is String) {
90
-
final text = record.trim();
91
-
if (text.length <= maxLength) return text;
92
-
return '${text.substring(0, maxLength)}...';
93
-
}
94
-
} catch (_) {
95
-
/* Failed to parse, try direct text access */
96
-
}
97
-
98
-
final text = (post.post.record['text'] as String? ?? '').trim();
99
-
if (text.isEmpty) return 'No preview available';
100
-
101
-
if (text.length <= maxLength) return text;
102
-
return '${text.substring(0, maxLength)}...';
103
-
}
104
-
105
-
String _formatReplyCount(int count) {
106
-
if (count == 1) return '1 reply hidden';
107
-
return '$count replies hidden';
108
-
}
109
-
}
-113
lib/src/features/thread/presentation/widgets/thread_curved_connector.dart
-113
lib/src/features/thread/presentation/widgets/thread_curved_connector.dart
···
1
-
import 'package:flutter/material.dart';
2
-
3
-
/// Style of connector line to render.
4
-
enum ConnectorStyle {
5
-
/// Parent to child connection with curve (for posts with children)
6
-
parentToChild,
7
-
8
-
/// Straight vertical line (for middle siblings in a chain)
9
-
continuation,
10
-
11
-
/// Terminal line ending at post (for last child or childless post)
12
-
terminal,
13
-
}
14
-
15
-
/// A curved connector widget for thread relationships with rust-analyzer style.
16
-
///
17
-
/// Renders smooth bezier curves connecting posts at different nesting levels,
18
-
/// providing visual hierarchy similar to rust-analyzer error displays.
19
-
class ThreadCurvedConnector extends StatelessWidget {
20
-
const ThreadCurvedConnector({
21
-
required this.style,
22
-
required this.depth,
23
-
this.width = 2,
24
-
this.color,
25
-
this.height,
26
-
super.key,
27
-
});
28
-
29
-
/// Style of connector to render
30
-
final ConnectorStyle style;
31
-
32
-
/// Current nesting depth (used for positioning calculations)
33
-
final int depth;
34
-
35
-
/// Width of the connector line
36
-
final double width;
37
-
38
-
/// Color of the connector line (defaults to theme divider color)
39
-
final Color? color;
40
-
41
-
/// Explicit height (if null, uses constraints)
42
-
final double? height;
43
-
44
-
@override
45
-
Widget build(BuildContext context) {
46
-
final lineColor = color ?? Theme.of(context).dividerColor;
47
-
48
-
return CustomPaint(
49
-
size: Size(width, height ?? double.infinity),
50
-
painter: _ThreadCurvedConnectorPainter(
51
-
color: lineColor,
52
-
strokeWidth: width,
53
-
style: style,
54
-
depth: depth,
55
-
),
56
-
);
57
-
}
58
-
}
59
-
60
-
class _ThreadCurvedConnectorPainter extends CustomPainter {
61
-
_ThreadCurvedConnectorPainter({
62
-
required this.color,
63
-
required this.strokeWidth,
64
-
required this.style,
65
-
required this.depth,
66
-
});
67
-
68
-
final Color color;
69
-
final double strokeWidth;
70
-
final ConnectorStyle style;
71
-
final int depth;
72
-
73
-
@override
74
-
void paint(Canvas canvas, Size size) {
75
-
final paint = Paint()
76
-
..color = color
77
-
..strokeWidth = strokeWidth
78
-
..style = PaintingStyle.stroke
79
-
..strokeCap = StrokeCap.round;
80
-
81
-
final centerX = size.width / 2;
82
-
const curveOffset = 32.0;
83
-
84
-
switch (style) {
85
-
case ConnectorStyle.parentToChild:
86
-
final curveStartY = size.height * 0.3;
87
-
canvas.drawLine(Offset(centerX, 0), Offset(centerX, curveStartY), paint);
88
-
89
-
final path = Path()
90
-
..moveTo(centerX, curveStartY)
91
-
..quadraticBezierTo(centerX, size.height * 0.5, centerX + curveOffset, size.height * 0.7)
92
-
..lineTo(centerX + curveOffset, size.height);
93
-
94
-
canvas.drawPath(path, paint);
95
-
96
-
case ConnectorStyle.continuation:
97
-
canvas.drawLine(Offset(centerX, 0), Offset(centerX, size.height), paint);
98
-
99
-
case ConnectorStyle.terminal:
100
-
final endY = size.height * 0.3;
101
-
canvas.drawLine(Offset(centerX, 0), Offset(centerX, endY), paint);
102
-
canvas.drawLine(Offset(centerX, endY), Offset(centerX + 20, endY), paint);
103
-
}
104
-
}
105
-
106
-
@override
107
-
bool shouldRepaint(covariant _ThreadCurvedConnectorPainter oldDelegate) {
108
-
return oldDelegate.color != color ||
109
-
oldDelegate.strokeWidth != strokeWidth ||
110
-
oldDelegate.style != style ||
111
-
oldDelegate.depth != depth;
112
-
}
113
-
}
+106
-48
lib/src/features/thread/presentation/widgets/thread_reply_item.dart
+106
-48
lib/src/features/thread/presentation/widgets/thread_reply_item.dart
···
3
3
import 'package:lazurite/src/features/feeds/presentation/screens/widgets/feed_post_card.dart';
4
4
import 'package:lazurite/src/features/thread/domain/thread.dart';
5
5
import 'package:lazurite/src/features/thread/domain/thread_layout_calculator.dart';
6
+
import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_header.dart';
6
7
import 'package:lazurite/src/features/thread/presentation/widgets/blocked_post_card.dart';
7
8
import 'package:lazurite/src/features/thread/presentation/widgets/collapse_toggle.dart';
8
9
import 'package:lazurite/src/features/thread/presentation/widgets/deep_thread_indicator.dart';
9
10
import 'package:lazurite/src/features/thread/presentation/widgets/not_found_post_card.dart';
10
-
import 'package:lazurite/src/features/thread/presentation/widgets/thread_curved_connector.dart';
11
11
12
12
/// A recursive reply item widget that renders nested thread replies.
13
13
///
···
19
19
required this.depth,
20
20
required this.isCollapsed,
21
21
required this.onToggleCollapse,
22
-
this.isLastSibling = false,
23
-
this.hasMoreSiblings = false,
24
22
super.key,
25
23
});
26
24
27
25
/// The thread post to render
28
26
final ThreadViewPost post;
29
27
30
-
/// Current nesting depth (0-based)
28
+
/// Current nesting depth (1-based for replies)
31
29
final int depth;
32
30
33
31
/// Whether this post's replies are collapsed
···
36
34
/// Callback when collapse toggle is tapped
37
35
final VoidCallback onToggleCollapse;
38
36
39
-
/// Whether this is the last sibling in its parent's reply list
40
-
final bool isLastSibling;
41
-
42
-
/// Whether there are more siblings after this one
43
-
final bool hasMoreSiblings;
37
+
static const _collapseAnimationDuration = Duration(milliseconds: 250);
38
+
static const _collapsedMaxChars = 140;
44
39
45
40
@override
46
41
Widget build(BuildContext context, WidgetRef ref) {
47
42
final indent = ThreadLayoutCalculator.calculateIndent(depth);
48
43
final shouldFlatten = ThreadLayoutCalculator.shouldFlattenDepth(depth);
49
44
final hasReplies = post.replies.isNotEmpty;
50
-
final connectorStyle = _determineConnectorStyle(hasReplies, isLastSibling);
51
45
52
46
return Column(
53
47
crossAxisAlignment: CrossAxisAlignment.start,
···
60
54
),
61
55
),
62
56
63
-
Stack(
64
-
children: [
65
-
if (depth > 0)
66
-
Positioned(
67
-
left: ThreadLayoutCalculator.calculateConnectorLeft(depth - 1),
68
-
top: 0,
69
-
bottom: hasMoreSiblings ? 0 : null,
70
-
height: hasMoreSiblings ? null : 60,
71
-
child: ThreadCurvedConnector(style: connectorStyle, depth: depth),
72
-
),
73
-
74
-
Padding(
75
-
padding: EdgeInsets.only(left: indent),
76
-
child: Row(
77
-
crossAxisAlignment: CrossAxisAlignment.start,
78
-
children: [
79
-
if (hasReplies)
80
-
CollapseToggle(
81
-
isCollapsed: isCollapsed,
82
-
onTap: onToggleCollapse,
83
-
replyCount: ThreadLayoutCalculator.countAllReplies(post),
84
-
showCount: isCollapsed,
85
-
)
86
-
else
87
-
const SizedBox(width: 24),
88
-
Expanded(child: _buildPostCard()),
89
-
],
57
+
Padding(
58
+
padding: EdgeInsets.only(left: indent),
59
+
child: Row(
60
+
crossAxisAlignment: CrossAxisAlignment.start,
61
+
children: [
62
+
if (hasReplies)
63
+
CollapseToggle(isCollapsed: isCollapsed, onTap: onToggleCollapse)
64
+
else
65
+
const SizedBox(width: 32),
66
+
Expanded(
67
+
child: AnimatedSwitcher(
68
+
duration: _collapseAnimationDuration,
69
+
switchInCurve: Curves.easeInOut,
70
+
switchOutCurve: Curves.easeInOut,
71
+
transitionBuilder: (child, animation) {
72
+
return FadeTransition(
73
+
opacity: animation,
74
+
child: SizeTransition(
75
+
sizeFactor: animation,
76
+
axisAlignment: -1,
77
+
child: child,
78
+
),
79
+
);
80
+
},
81
+
child: isCollapsed
82
+
? _CollapsedPostSummary(
83
+
key: const ValueKey('collapsed_summary'),
84
+
post: post,
85
+
maxCharacters: _collapsedMaxChars,
86
+
)
87
+
: KeyedSubtree(
88
+
key: const ValueKey('expanded_post'),
89
+
child: _buildPostCard(),
90
+
),
91
+
),
90
92
),
91
-
),
92
-
],
93
+
],
94
+
),
93
95
),
94
96
],
95
97
);
···
104
106
return const NotFoundPostCard();
105
107
}
106
108
107
-
return FeedPostCard(item: post.post.toFeedPost());
109
+
return FeedPostCard(
110
+
item: post.post.toFeedPost(),
111
+
margin: const EdgeInsets.symmetric(vertical: 8),
112
+
);
113
+
}
114
+
}
115
+
116
+
class _CollapsedPostSummary extends StatelessWidget {
117
+
const _CollapsedPostSummary({required this.post, required this.maxCharacters, super.key});
118
+
119
+
final ThreadViewPost post;
120
+
final int maxCharacters;
121
+
122
+
@override
123
+
Widget build(BuildContext context) {
124
+
final theme = Theme.of(context);
125
+
final colorScheme = theme.colorScheme;
126
+
final textTheme = theme.textTheme;
127
+
128
+
final extractedText = _extractText();
129
+
final previewText = extractedText.isEmpty ? 'No preview available' : extractedText;
130
+
final repliesLabel = post.replies.isEmpty ? null : _formatReplies(post.replies.length);
131
+
132
+
return Container(
133
+
padding: const EdgeInsets.all(12),
134
+
decoration: BoxDecoration(
135
+
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
136
+
borderRadius: BorderRadius.circular(12),
137
+
border: Border.all(color: colorScheme.outlineVariant),
138
+
),
139
+
child: Column(
140
+
crossAxisAlignment: CrossAxisAlignment.start,
141
+
children: [
142
+
PostHeader(author: post.post.toProfileModel(), indexedAt: post.post.indexedAt),
143
+
const SizedBox(height: 8),
144
+
Text(
145
+
previewText,
146
+
maxLines: 2,
147
+
overflow: TextOverflow.ellipsis,
148
+
style: textTheme.bodyMedium,
149
+
),
150
+
if (repliesLabel != null) ...[
151
+
const SizedBox(height: 8),
152
+
Text(
153
+
repliesLabel,
154
+
style: textTheme.labelSmall?.copyWith(
155
+
color: colorScheme.primary,
156
+
fontWeight: FontWeight.w600,
157
+
),
158
+
),
159
+
],
160
+
],
161
+
),
162
+
);
108
163
}
109
164
110
-
ConnectorStyle _determineConnectorStyle(bool hasReplies, bool isLast) {
111
-
if (hasReplies && !isCollapsed) {
112
-
return ConnectorStyle.parentToChild;
113
-
}
114
-
if (isLast) {
115
-
return ConnectorStyle.terminal;
116
-
}
117
-
return ConnectorStyle.continuation;
165
+
String _extractText() {
166
+
final raw = post.post.record['text'];
167
+
if (raw is! String) return '';
168
+
final trimmed = raw.trim();
169
+
if (trimmed.length <= maxCharacters) return trimmed;
170
+
return '${trimmed.substring(0, maxCharacters)}…';
171
+
}
172
+
173
+
String _formatReplies(int count) {
174
+
final noun = count == 1 ? 'reply hidden' : 'replies hidden';
175
+
return '$count $noun';
118
176
}
119
177
}
+20
test/src/features/feeds/presentation/widgets/post/post_actions_row_test.dart
+20
test/src/features/feeds/presentation/widgets/post/post_actions_row_test.dart
···
153
153
expect(find.byIcon(Icons.bookmark), findsNothing);
154
154
});
155
155
});
156
+
157
+
testWidgets('hides counts in narrow layout', (tester) async {
158
+
await tester.pumpWidget(
159
+
const MaterialApp(
160
+
home: Scaffold(
161
+
body: Center(
162
+
child: SizedBox(
163
+
width: 150,
164
+
child: PostActionsRow(replyCount: 5, repostCount: 15, likeCount: 30),
165
+
),
166
+
),
167
+
),
168
+
),
169
+
);
170
+
171
+
expect(find.text('5'), findsNothing);
172
+
expect(find.text('15'), findsNothing);
173
+
expect(find.text('30'), findsNothing);
174
+
expect(find.byIcon(Icons.chat_bubble_outline), findsOneWidget);
175
+
});
156
176
});
157
177
}
+8
-39
test/src/features/thread/domain/thread_layout_calculator_test.dart
+8
-39
test/src/features/thread/domain/thread_layout_calculator_test.dart
···
10
10
});
11
11
12
12
test('calculates indent for various depths', () {
13
-
expect(ThreadLayoutCalculator.calculateIndent(1), 32.0);
14
-
expect(ThreadLayoutCalculator.calculateIndent(2), 64.0);
15
-
expect(ThreadLayoutCalculator.calculateIndent(3), 96.0);
16
-
expect(ThreadLayoutCalculator.calculateIndent(4), 128.0);
17
-
expect(ThreadLayoutCalculator.calculateIndent(5), 160.0);
13
+
expect(ThreadLayoutCalculator.calculateIndent(1), 12.0);
14
+
expect(ThreadLayoutCalculator.calculateIndent(2), 24.0);
15
+
expect(ThreadLayoutCalculator.calculateIndent(3), 36.0);
16
+
expect(ThreadLayoutCalculator.calculateIndent(4), 48.0);
17
+
expect(ThreadLayoutCalculator.calculateIndent(5), 60.0);
18
18
});
19
19
20
20
test('clamps indent at maxDepth', () {
21
-
expect(ThreadLayoutCalculator.calculateIndent(6), 160.0);
22
-
expect(ThreadLayoutCalculator.calculateIndent(10), 160.0);
23
-
expect(ThreadLayoutCalculator.calculateIndent(100), 160.0);
21
+
expect(ThreadLayoutCalculator.calculateIndent(6), 60.0);
22
+
expect(ThreadLayoutCalculator.calculateIndent(10), 60.0);
23
+
expect(ThreadLayoutCalculator.calculateIndent(100), 60.0);
24
24
});
25
25
26
26
test('handles negative depth gracefully', () {
27
27
expect(ThreadLayoutCalculator.calculateIndent(-1), 0.0);
28
28
expect(ThreadLayoutCalculator.calculateIndent(-10), 0.0);
29
-
});
30
-
});
31
-
32
-
group('calculateConnectorLeft', () {
33
-
test('positions connector at avatar center for depth 0', () {
34
-
expect(
35
-
ThreadLayoutCalculator.calculateConnectorLeft(0),
36
-
20.0, // 0 + avatarCenterOffset
37
-
);
38
-
});
39
-
40
-
test('calculates connector position relative to indent', () {
41
-
expect(
42
-
ThreadLayoutCalculator.calculateConnectorLeft(1),
43
-
52.0, // 32 + 20
44
-
);
45
-
expect(
46
-
ThreadLayoutCalculator.calculateConnectorLeft(2),
47
-
84.0, // 64 + 20
48
-
);
49
-
expect(
50
-
ThreadLayoutCalculator.calculateConnectorLeft(3),
51
-
116.0, // 96 + 20
52
-
);
53
-
});
54
-
55
-
test('clamps connector position at maxDepth', () {
56
-
final maxConnectorLeft = ThreadLayoutCalculator.calculateConnectorLeft(
57
-
ThreadLayoutCalculator.maxDepth,
58
-
);
59
-
expect(ThreadLayoutCalculator.calculateConnectorLeft(10), maxConnectorLeft);
60
29
});
61
30
});
62
31
+14
-63
test/src/features/thread/presentation/widgets/collapse_toggle_test.dart
+14
-63
test/src/features/thread/presentation/widgets/collapse_toggle_test.dart
···
1
+
import 'package:flutter/cupertino.dart';
1
2
import 'package:flutter/material.dart';
2
3
import 'package:flutter_test/flutter_test.dart';
3
4
import 'package:lazurite/src/features/thread/presentation/widgets/collapse_toggle.dart';
···
15
16
16
17
expect(find.byType(CollapseToggle), findsOneWidget);
17
18
expect(find.byType(Icon), findsOneWidget);
18
-
expect(find.byIcon(Icons.play_arrow), findsOneWidget);
19
+
expect(find.byIcon(CupertinoIcons.chevron_down_circle), findsOneWidget);
19
20
20
21
await tester.tap(find.byType(CollapseToggle));
21
22
await tester.pump();
···
31
32
);
32
33
33
34
expect(find.byType(CollapseToggle), findsOneWidget);
34
-
expect(find.byIcon(Icons.play_arrow), findsOneWidget);
35
-
});
36
-
37
-
testWidgets('shows reply count badge when showCount is true', (tester) async {
38
-
await tester.pumpWidget(
39
-
MaterialApp(
40
-
home: Scaffold(
41
-
body: CollapseToggle(isCollapsed: false, onTap: () {}, replyCount: 5, showCount: true),
42
-
),
43
-
),
44
-
);
45
-
46
-
expect(find.text('5'), findsOneWidget);
35
+
expect(find.byIcon(CupertinoIcons.chevron_right_circle), findsOneWidget);
47
36
});
48
37
49
-
testWidgets('hides reply count badge when showCount is false', (tester) async {
50
-
await tester.pumpWidget(
51
-
MaterialApp(
52
-
home: Scaffold(
53
-
body: CollapseToggle(
54
-
isCollapsed: false,
55
-
onTap: () {},
56
-
replyCount: 5,
57
-
showCount: false,
58
-
),
59
-
),
60
-
),
61
-
);
62
-
63
-
expect(find.text('5'), findsNothing);
64
-
});
65
-
66
-
testWidgets('hides badge when replyCount is 0', (tester) async {
67
-
await tester.pumpWidget(
68
-
MaterialApp(
69
-
home: Scaffold(
70
-
body: CollapseToggle(isCollapsed: false, onTap: () {}, replyCount: 0, showCount: true),
71
-
),
72
-
),
73
-
);
74
-
75
-
expect(find.byType(Container), findsNothing);
76
-
});
77
-
78
-
testWidgets('icon rotates when toggling collapse state', (tester) async {
38
+
testWidgets('switches chevron direction when toggling', (tester) async {
79
39
var isCollapsed = false;
80
40
81
41
await tester.pumpWidget(
···
93
53
),
94
54
);
95
55
96
-
var rotation = tester.widget<AnimatedRotation>(find.byType(AnimatedRotation));
97
-
expect(rotation.turns, 0);
56
+
expect(find.byIcon(CupertinoIcons.chevron_down_circle), findsOneWidget);
98
57
99
58
await tester.tap(find.byType(CollapseToggle));
100
59
await tester.pump();
101
60
102
-
rotation = tester.widget<AnimatedRotation>(find.byType(AnimatedRotation));
103
-
expect(rotation.turns, -0.25);
61
+
expect(find.byIcon(CupertinoIcons.chevron_right_circle), findsOneWidget);
104
62
});
105
63
106
-
testWidgets('displays different reply counts', (tester) async {
107
-
for (final count in [1, 10, 100, 999]) {
108
-
await tester.pumpWidget(
109
-
MaterialApp(
110
-
home: Scaffold(
111
-
body: CollapseToggle(
112
-
isCollapsed: false,
113
-
onTap: () {},
114
-
replyCount: count,
115
-
showCount: true,
116
-
),
117
-
),
64
+
testWidgets('reserves consistent tap target size', (tester) async {
65
+
await tester.pumpWidget(
66
+
MaterialApp(
67
+
home: Scaffold(
68
+
body: Center(child: CollapseToggle(isCollapsed: false, onTap: () {})),
118
69
),
119
-
);
70
+
),
71
+
);
120
72
121
-
expect(find.text(count.toString()), findsOneWidget);
122
-
}
73
+
expect(tester.getSize(find.byType(CollapseToggle)), const Size(32, 32));
123
74
});
124
75
});
125
76
}
-120
test/src/features/thread/presentation/widgets/thread_curved_connector_test.dart
-120
test/src/features/thread/presentation/widgets/thread_curved_connector_test.dart
···
1
-
import 'package:flutter/material.dart';
2
-
import 'package:flutter_test/flutter_test.dart';
3
-
import 'package:lazurite/src/features/thread/presentation/widgets/thread_curved_connector.dart';
4
-
5
-
void main() {
6
-
group('ThreadCurvedConnector', () {
7
-
testWidgets('renders with parentToChild style', (tester) async {
8
-
await tester.pumpWidget(
9
-
const MaterialApp(
10
-
home: Scaffold(
11
-
body: SizedBox(
12
-
width: 100,
13
-
height: 200,
14
-
child: ThreadCurvedConnector(style: ConnectorStyle.parentToChild, depth: 1),
15
-
),
16
-
),
17
-
),
18
-
);
19
-
20
-
expect(find.byType(ThreadCurvedConnector), findsOneWidget);
21
-
});
22
-
23
-
testWidgets('renders with continuation style', (tester) async {
24
-
await tester.pumpWidget(
25
-
const MaterialApp(
26
-
home: Scaffold(
27
-
body: SizedBox(
28
-
width: 100,
29
-
height: 200,
30
-
child: ThreadCurvedConnector(style: ConnectorStyle.continuation, depth: 2),
31
-
),
32
-
),
33
-
),
34
-
);
35
-
36
-
expect(find.byType(ThreadCurvedConnector), findsOneWidget);
37
-
});
38
-
39
-
testWidgets('renders with terminal style', (tester) async {
40
-
await tester.pumpWidget(
41
-
const MaterialApp(
42
-
home: Scaffold(
43
-
body: SizedBox(
44
-
width: 100,
45
-
height: 200,
46
-
child: ThreadCurvedConnector(style: ConnectorStyle.terminal, depth: 3),
47
-
),
48
-
),
49
-
),
50
-
);
51
-
52
-
expect(find.byType(ThreadCurvedConnector), findsOneWidget);
53
-
});
54
-
55
-
testWidgets('uses custom color when provided', (tester) async {
56
-
const customColor = Colors.red;
57
-
58
-
await tester.pumpWidget(
59
-
const MaterialApp(
60
-
home: Scaffold(
61
-
body: SizedBox(
62
-
width: 100,
63
-
height: 200,
64
-
child: ThreadCurvedConnector(
65
-
style: ConnectorStyle.parentToChild,
66
-
depth: 1,
67
-
color: customColor,
68
-
),
69
-
),
70
-
),
71
-
),
72
-
);
73
-
74
-
final connector = tester.widget<ThreadCurvedConnector>(find.byType(ThreadCurvedConnector));
75
-
expect(connector.color, customColor);
76
-
});
77
-
78
-
testWidgets('uses custom width when provided', (tester) async {
79
-
const customWidth = 4.0;
80
-
81
-
await tester.pumpWidget(
82
-
const MaterialApp(
83
-
home: Scaffold(
84
-
body: SizedBox(
85
-
width: 100,
86
-
height: 200,
87
-
child: ThreadCurvedConnector(
88
-
style: ConnectorStyle.parentToChild,
89
-
depth: 1,
90
-
width: customWidth,
91
-
),
92
-
),
93
-
),
94
-
),
95
-
);
96
-
97
-
final connector = tester.widget<ThreadCurvedConnector>(find.byType(ThreadCurvedConnector));
98
-
expect(connector.width, customWidth);
99
-
});
100
-
101
-
testWidgets('accepts different depth values', (tester) async {
102
-
for (var depth = 0; depth <= 5; depth++) {
103
-
await tester.pumpWidget(
104
-
MaterialApp(
105
-
home: Scaffold(
106
-
body: SizedBox(
107
-
width: 100,
108
-
height: 200,
109
-
child: ThreadCurvedConnector(style: ConnectorStyle.parentToChild, depth: depth),
110
-
),
111
-
),
112
-
),
113
-
);
114
-
115
-
final connector = tester.widget<ThreadCurvedConnector>(find.byType(ThreadCurvedConnector));
116
-
expect(connector.depth, depth);
117
-
}
118
-
});
119
-
});
120
-
}
+82
test/src/features/thread/presentation/widgets/thread_reply_item_test.dart
+82
test/src/features/thread/presentation/widgets/thread_reply_item_test.dart
···
1
1
import 'package:flutter/material.dart';
2
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
3
import 'package:flutter_test/flutter_test.dart';
4
+
import 'package:lazurite/src/app/providers.dart';
5
+
import 'package:lazurite/src/core/domain/content_label.dart';
6
+
import 'package:lazurite/src/features/settings/application/label_filter_provider.dart';
7
+
import 'package:lazurite/src/features/settings/domain/label_filter_service.dart';
4
8
import 'package:lazurite/src/features/thread/domain/thread.dart';
5
9
import 'package:lazurite/src/features/thread/presentation/widgets/thread_reply_item.dart';
10
+
import 'package:mocktail/mocktail.dart';
11
+
12
+
import '../../../../../helpers/mocks.dart';
13
+
14
+
class MockLabelFilterService extends Mock implements LabelFilterService {}
6
15
7
16
void main() {
17
+
late MockAppDatabase mockDatabase;
18
+
late MockLabelFilterService mockLabelFilter;
19
+
20
+
setUpAll(() {
21
+
registerFallbackValue(<ContentLabel>[]);
22
+
});
23
+
24
+
setUp(() {
25
+
mockDatabase = MockAppDatabase();
26
+
mockLabelFilter = MockLabelFilterService();
27
+
28
+
when(() => mockLabelFilter.anyLabelWarns(any())).thenReturn(false);
29
+
when(() => mockLabelFilter.anyLabelHides(any())).thenReturn(false);
30
+
});
31
+
8
32
group('ThreadReplyItem', () {
9
33
testWidgets('renders post at depth 0', (tester) async {
10
34
final post = _createThreadViewPost(handle: 'user1');
11
35
12
36
await tester.pumpWidget(
13
37
ProviderScope(
38
+
overrides: [
39
+
appDatabaseProvider.overrideWithValue(mockDatabase),
40
+
labelFilterServiceProvider.overrideWithValue(mockLabelFilter),
41
+
],
14
42
child: MaterialApp(
15
43
home: Scaffold(
16
44
body: ThreadReplyItem(
···
38
66
39
67
await tester.pumpWidget(
40
68
ProviderScope(
69
+
overrides: [
70
+
appDatabaseProvider.overrideWithValue(mockDatabase),
71
+
labelFilterServiceProvider.overrideWithValue(mockLabelFilter),
72
+
],
41
73
child: MaterialApp(
42
74
home: Scaffold(
43
75
body: ThreadReplyItem(
···
59
91
60
92
await tester.pumpWidget(
61
93
ProviderScope(
94
+
overrides: [
95
+
appDatabaseProvider.overrideWithValue(mockDatabase),
96
+
labelFilterServiceProvider.overrideWithValue(mockLabelFilter),
97
+
],
62
98
child: MaterialApp(
63
99
home: Scaffold(
64
100
body: ThreadReplyItem(
···
81
117
82
118
await tester.pumpWidget(
83
119
ProviderScope(
120
+
overrides: [
121
+
appDatabaseProvider.overrideWithValue(mockDatabase),
122
+
labelFilterServiceProvider.overrideWithValue(mockLabelFilter),
123
+
],
84
124
child: MaterialApp(
85
125
home: Scaffold(
86
126
body: ThreadReplyItem(
···
105
145
106
146
await tester.pumpWidget(
107
147
ProviderScope(
148
+
overrides: [
149
+
appDatabaseProvider.overrideWithValue(mockDatabase),
150
+
labelFilterServiceProvider.overrideWithValue(mockLabelFilter),
151
+
],
108
152
child: MaterialApp(
109
153
home: Scaffold(
110
154
body: ThreadReplyItem(
···
126
170
127
171
await tester.pumpWidget(
128
172
ProviderScope(
173
+
overrides: [
174
+
appDatabaseProvider.overrideWithValue(mockDatabase),
175
+
labelFilterServiceProvider.overrideWithValue(mockLabelFilter),
176
+
],
129
177
child: MaterialApp(
130
178
home: Scaffold(
131
179
body: ThreadReplyItem(
···
147
195
148
196
await tester.pumpWidget(
149
197
ProviderScope(
198
+
overrides: [
199
+
appDatabaseProvider.overrideWithValue(mockDatabase),
200
+
labelFilterServiceProvider.overrideWithValue(mockLabelFilter),
201
+
],
150
202
child: MaterialApp(
151
203
home: Scaffold(
152
204
body: ThreadReplyItem(
···
161
213
);
162
214
163
215
expect(find.byType(ThreadReplyItem), findsOneWidget);
216
+
});
217
+
218
+
testWidgets('shows truncated summary and hidden count when collapsed', (tester) async {
219
+
final post = _createThreadViewPost(
220
+
handle: 'user1',
221
+
replies: [_createThreadViewPost(handle: 'child')],
222
+
);
223
+
224
+
await tester.pumpWidget(
225
+
ProviderScope(
226
+
overrides: [
227
+
appDatabaseProvider.overrideWithValue(mockDatabase),
228
+
labelFilterServiceProvider.overrideWithValue(mockLabelFilter),
229
+
],
230
+
child: MaterialApp(
231
+
home: Scaffold(
232
+
body: ThreadReplyItem(
233
+
post: post,
234
+
depth: 0,
235
+
isCollapsed: true,
236
+
onToggleCollapse: () {},
237
+
),
238
+
),
239
+
),
240
+
),
241
+
);
242
+
243
+
expect(find.text('@user1'), findsOneWidget);
244
+
expect(find.textContaining('Test post from user1'), findsOneWidget);
245
+
expect(find.text('1 reply hidden'), findsOneWidget);
164
246
});
165
247
});
166
248
}