+46
lib/src/features/thread/application/thread_collapse_state.dart
+46
lib/src/features/thread/application/thread_collapse_state.dart
···
1
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
2
+
3
+
part 'thread_collapse_state.g.dart';
4
+
5
+
/// Manages collapse/expand state for thread posts.
6
+
///
7
+
/// State is session-only and keyed by post URI. Posts are expanded by default.
8
+
@riverpod
9
+
class ThreadCollapseState extends _$ThreadCollapseState {
10
+
@override
11
+
Map<String, bool> build() => {};
12
+
13
+
/// Check if a post is collapsed.
14
+
///
15
+
/// Returns false (expanded) if post URI is not in the map.
16
+
bool isCollapsed(String postUri) => state[postUri] ?? false;
17
+
18
+
/// Toggle collapse state for a post.
19
+
void toggle(String postUri) {
20
+
state = {...state, postUri: !(state[postUri] ?? false)};
21
+
}
22
+
23
+
/// Collapse all posts in the provided list.
24
+
void collapseAll(List<String> uris) {
25
+
final newState = {...state};
26
+
for (final uri in uris) {
27
+
newState[uri] = true;
28
+
}
29
+
state = newState;
30
+
}
31
+
32
+
/// Expand all posts (clear all collapse state).
33
+
void expandAll() {
34
+
state = {};
35
+
}
36
+
37
+
/// Collapse a specific post.
38
+
void collapse(String postUri) {
39
+
state = {...state, postUri: true};
40
+
}
41
+
42
+
/// Expand a specific post.
43
+
void expand(String postUri) {
44
+
state = {...state, postUri: false};
45
+
}
46
+
}
+75
lib/src/features/thread/application/thread_collapse_state.g.dart
+75
lib/src/features/thread/application/thread_collapse_state.g.dart
···
1
+
// GENERATED CODE - DO NOT MODIFY BY HAND
2
+
3
+
part of 'thread_collapse_state.dart';
4
+
5
+
// **************************************************************************
6
+
// RiverpodGenerator
7
+
// **************************************************************************
8
+
9
+
// GENERATED CODE - DO NOT MODIFY BY HAND
10
+
// ignore_for_file: type=lint, type=warning
11
+
/// Manages collapse/expand state for thread posts.
12
+
///
13
+
/// State is session-only and keyed by post URI. Posts are expanded by default.
14
+
15
+
@ProviderFor(ThreadCollapseState)
16
+
final threadCollapseStateProvider = ThreadCollapseStateProvider._();
17
+
18
+
/// Manages collapse/expand state for thread posts.
19
+
///
20
+
/// State is session-only and keyed by post URI. Posts are expanded by default.
21
+
final class ThreadCollapseStateProvider
22
+
extends $NotifierProvider<ThreadCollapseState, Map<String, bool>> {
23
+
/// Manages collapse/expand state for thread posts.
24
+
///
25
+
/// State is session-only and keyed by post URI. Posts are expanded by default.
26
+
ThreadCollapseStateProvider._()
27
+
: super(
28
+
from: null,
29
+
argument: null,
30
+
retry: null,
31
+
name: r'threadCollapseStateProvider',
32
+
isAutoDispose: true,
33
+
dependencies: null,
34
+
$allTransitiveDependencies: null,
35
+
);
36
+
37
+
@override
38
+
String debugGetCreateSourceHash() => _$threadCollapseStateHash();
39
+
40
+
@$internal
41
+
@override
42
+
ThreadCollapseState create() => ThreadCollapseState();
43
+
44
+
/// {@macro riverpod.override_with_value}
45
+
Override overrideWithValue(Map<String, bool> value) {
46
+
return $ProviderOverride(
47
+
origin: this,
48
+
providerOverride: $SyncValueProvider<Map<String, bool>>(value),
49
+
);
50
+
}
51
+
}
52
+
53
+
String _$threadCollapseStateHash() => r'e68ffefdf271e20807ee067a302979b277f3ad5a';
54
+
55
+
/// Manages collapse/expand state for thread posts.
56
+
///
57
+
/// State is session-only and keyed by post URI. Posts are expanded by default.
58
+
59
+
abstract class _$ThreadCollapseState extends $Notifier<Map<String, bool>> {
60
+
Map<String, bool> build();
61
+
@$mustCallSuper
62
+
@override
63
+
void runBuild() {
64
+
final ref = this.ref as $Ref<Map<String, bool>, Map<String, bool>>;
65
+
final element =
66
+
ref.element
67
+
as $ClassProviderElement<
68
+
AnyNotifier<Map<String, bool>, Map<String, bool>>,
69
+
Map<String, bool>,
70
+
Object?,
71
+
Object?
72
+
>;
73
+
element.handleCreate(ref, build);
74
+
}
75
+
}
+2
lib/src/features/thread/application/thread_providers.dart
+2
lib/src/features/thread/application/thread_providers.dart
+76
lib/src/features/thread/domain/thread_layout_calculator.dart
+76
lib/src/features/thread/domain/thread_layout_calculator.dart
···
1
+
import 'package:lazurite/src/features/thread/domain/thread.dart';
2
+
3
+
/// Utility class for thread layout calculations.
4
+
///
5
+
/// Provides constants and methods for computing indentation, connector
6
+
/// positioning, and depth management for nested thread rendering.
7
+
class ThreadLayoutCalculator {
8
+
ThreadLayoutCalculator._();
9
+
10
+
/// Base indentation for depth 0 posts
11
+
static const double baseIndent = 0.0;
12
+
13
+
/// Additional indentation per nesting level
14
+
static const double indentPerLevel = 32.0;
15
+
16
+
/// Maximum depth before flattening
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
+
25
+
/// Calculate left padding for a post at given depth.
26
+
///
27
+
/// Depth is clamped to [maxDepth] to prevent infinite horizontal scrolling.
28
+
static double calculateIndent(int depth) {
29
+
final effectiveDepth = depth.clamp(0, maxDepth);
30
+
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
+
}
39
+
40
+
/// Determine if depth should be flattened.
41
+
///
42
+
/// Returns true if depth exceeds [maxDepth], indicating that further
43
+
/// nesting should be prevented to maintain readability.
44
+
static bool shouldFlattenDepth(int depth) {
45
+
return depth > maxDepth;
46
+
}
47
+
48
+
/// Calculate effective depth (accounts for max depth flattening).
49
+
///
50
+
/// Returns the actual depth value that should be used for rendering,
51
+
/// clamped to [maxDepth].
52
+
static int calculateEffectiveDepth(int actualDepth) {
53
+
return actualDepth.clamp(0, maxDepth);
54
+
}
55
+
56
+
/// Get parent context for deep threads.
57
+
///
58
+
/// Returns a string like "@username" to show which post this is replying to
59
+
/// when depth is flattened.
60
+
static String getParentContext(ThreadViewPost post) {
61
+
if (post.parent == null) return '';
62
+
final handle = post.parent!.post.author.handle;
63
+
return '@$handle';
64
+
}
65
+
66
+
/// Count total number of replies (including nested).
67
+
///
68
+
/// Recursively counts all descendants of a post.
69
+
static int countAllReplies(ThreadViewPost post) {
70
+
var count = post.replies.length;
71
+
for (final reply in post.replies) {
72
+
count += countAllReplies(reply);
73
+
}
74
+
return count;
75
+
}
76
+
}
+107
-5
lib/src/features/thread/presentation/thread_screen.dart
+107
-5
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';
11
12
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';
12
14
import 'package:lazurite/src/features/thread/presentation/widgets/not_found_post_card.dart';
13
15
import 'package:lazurite/src/features/thread/presentation/widgets/thread_line_connector.dart';
16
+
import 'package:lazurite/src/features/thread/presentation/widgets/thread_reply_item.dart';
14
17
import 'package:lazurite/src/features/thread/presentation/widgets/threadgate_indicator.dart';
15
18
import 'package:lazurite/src/infrastructure/db/daos/feed_content_dao.dart';
16
19
···
41
44
tooltip: _flattenedView ? 'Switch to Tree View' : 'Switch to Flattened View',
42
45
onPressed: () => setState(() => _flattenedView = !_flattenedView),
43
46
),
47
+
if (!_flattenedView)
48
+
threadAsync.maybeWhen(
49
+
data: (thread) => PopupMenuButton<String>(
50
+
icon: const Icon(Icons.more_vert),
51
+
tooltip: 'Thread options',
52
+
onSelected: (value) {
53
+
if (value == 'collapse_all') {
54
+
_collapseAllReplies(thread);
55
+
} else if (value == 'expand_all') {
56
+
ref.read(threadCollapseStateProvider.notifier).expandAll();
57
+
}
58
+
},
59
+
itemBuilder: (context) => [
60
+
const PopupMenuItem(
61
+
value: 'collapse_all',
62
+
child: Row(
63
+
children: [
64
+
Icon(Icons.unfold_less),
65
+
SizedBox(width: 12),
66
+
Text('Collapse all replies'),
67
+
],
68
+
),
69
+
),
70
+
const PopupMenuItem(
71
+
value: 'expand_all',
72
+
child: Row(
73
+
children: [
74
+
Icon(Icons.unfold_more),
75
+
SizedBox(width: 12),
76
+
Text('Expand all replies'),
77
+
],
78
+
),
79
+
),
80
+
],
81
+
),
82
+
orElse: () => const SizedBox.shrink(),
83
+
),
44
84
],
45
85
),
46
86
body: threadAsync.when(
···
90
130
SliverList(
91
131
delegate: SliverChildBuilderDelegate((context, index) {
92
132
final reply = sortedReplies[index];
93
-
final position = _getThreadLinePosition(
94
-
index: index,
95
-
total: sortedReplies.length,
96
-
isParentChain: false,
133
+
return _buildReplyTree(
134
+
reply,
135
+
depth: 1,
136
+
isLastSibling: index == sortedReplies.length - 1,
97
137
);
98
-
return _buildThreadPost(reply, position: position, isReply: true);
99
138
}, childCount: sortedReplies.length),
100
139
),
101
140
],
···
238
277
current = current.parent!;
239
278
}
240
279
return list.reversed.toList();
280
+
}
281
+
282
+
/// Collects all reply URIs for collapse/expand all
283
+
void _collapseAllReplies(ThreadViewPost thread) {
284
+
final uris = <String>[];
285
+
void collectUris(ThreadViewPost post) {
286
+
if (post.replies.isNotEmpty) {
287
+
uris.add(post.post.uri);
288
+
for (final reply in post.replies) {
289
+
collectUris(reply);
290
+
}
291
+
}
292
+
}
293
+
294
+
for (final reply in thread.replies) {
295
+
collectUris(reply);
296
+
}
297
+
298
+
ref.read(threadCollapseStateProvider.notifier).collapseAll(uris);
299
+
}
300
+
301
+
/// Recursively builds reply tree with nesting and collapse support
302
+
Widget _buildReplyTree(ThreadViewPost post, {required int depth, bool isLastSibling = false}) {
303
+
final isCollapsed = ref.watch(
304
+
threadCollapseStateProvider.select((state) => state[post.post.uri] ?? false),
305
+
);
306
+
307
+
return Column(
308
+
crossAxisAlignment: CrossAxisAlignment.start,
309
+
children: [
310
+
ThreadReplyItem(
311
+
post: post,
312
+
depth: depth,
313
+
isCollapsed: isCollapsed,
314
+
onToggleCollapse: () {
315
+
ref.read(threadCollapseStateProvider.notifier).toggle(post.post.uri);
316
+
},
317
+
isLastSibling: isLastSibling,
318
+
hasMoreSiblings: !isLastSibling,
319
+
),
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
+
),
341
+
],
342
+
);
241
343
}
242
344
243
345
/// Helper to flatten replies purely for the list
+69
lib/src/features/thread/presentation/widgets/collapse_toggle.dart
+69
lib/src/features/thread/presentation/widgets/collapse_toggle.dart
···
1
+
import 'package:flutter/material.dart';
2
+
3
+
/// A collapsible toggle button with triangle icon and reply count badge.
4
+
///
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.
7
+
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
+
});
15
+
16
+
/// Whether the associated thread is currently collapsed
17
+
final bool isCollapsed;
18
+
19
+
/// Callback when toggle is tapped
20
+
final VoidCallback onTap;
21
+
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
+
@override
29
+
Widget build(BuildContext context) {
30
+
final theme = Theme.of(context);
31
+
final colorScheme = theme.colorScheme;
32
+
33
+
return InkWell(
34
+
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
+
),
66
+
),
67
+
);
68
+
}
69
+
}
+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
+
}
+46
lib/src/features/thread/presentation/widgets/deep_thread_indicator.dart
+46
lib/src/features/thread/presentation/widgets/deep_thread_indicator.dart
···
1
+
import 'package:flutter/material.dart';
2
+
3
+
/// Indicator shown when thread depth exceeds maximum nesting level.
4
+
///
5
+
/// Displays parent context (e.g., "Replying to @username") to maintain
6
+
/// conversation context when nesting is flattened.
7
+
class DeepThreadIndicator extends StatelessWidget {
8
+
const DeepThreadIndicator({required this.parentHandle, super.key});
9
+
10
+
/// Handle of the parent post author
11
+
final String parentHandle;
12
+
13
+
@override
14
+
Widget build(BuildContext context) {
15
+
if (parentHandle.isEmpty) {
16
+
return const SizedBox.shrink();
17
+
}
18
+
19
+
final theme = Theme.of(context);
20
+
final colorScheme = theme.colorScheme;
21
+
22
+
return Container(
23
+
margin: const EdgeInsets.only(top: 4, bottom: 8, right: 8),
24
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
25
+
decoration: BoxDecoration(
26
+
color: colorScheme.secondaryContainer.withValues(alpha: 0.5),
27
+
borderRadius: BorderRadius.circular(4),
28
+
border: Border.all(color: colorScheme.outline.withValues(alpha: 0.3), width: 1),
29
+
),
30
+
child: Row(
31
+
mainAxisSize: MainAxisSize.min,
32
+
children: [
33
+
Icon(Icons.subdirectory_arrow_right, size: 14, color: colorScheme.onSecondaryContainer),
34
+
const SizedBox(width: 6),
35
+
Text(
36
+
'Replying to @$parentHandle',
37
+
style: theme.textTheme.labelSmall?.copyWith(
38
+
color: colorScheme.onSecondaryContainer,
39
+
fontStyle: FontStyle.italic,
40
+
),
41
+
),
42
+
],
43
+
),
44
+
);
45
+
}
46
+
}
+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
+
}
+119
lib/src/features/thread/presentation/widgets/thread_reply_item.dart
+119
lib/src/features/thread/presentation/widgets/thread_reply_item.dart
···
1
+
import 'package:flutter/material.dart';
2
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+
import 'package:lazurite/src/features/feeds/presentation/screens/widgets/feed_post_card.dart';
4
+
import 'package:lazurite/src/features/thread/domain/thread.dart';
5
+
import 'package:lazurite/src/features/thread/domain/thread_layout_calculator.dart';
6
+
import 'package:lazurite/src/features/thread/presentation/widgets/blocked_post_card.dart';
7
+
import 'package:lazurite/src/features/thread/presentation/widgets/collapse_toggle.dart';
8
+
import 'package:lazurite/src/features/thread/presentation/widgets/deep_thread_indicator.dart';
9
+
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
+
12
+
/// A recursive reply item widget that renders nested thread replies.
13
+
///
14
+
/// Handles indentation based on depth, collapse/expand state, and renders
15
+
/// child replies recursively.
16
+
class ThreadReplyItem extends ConsumerWidget {
17
+
const ThreadReplyItem({
18
+
required this.post,
19
+
required this.depth,
20
+
required this.isCollapsed,
21
+
required this.onToggleCollapse,
22
+
this.isLastSibling = false,
23
+
this.hasMoreSiblings = false,
24
+
super.key,
25
+
});
26
+
27
+
/// The thread post to render
28
+
final ThreadViewPost post;
29
+
30
+
/// Current nesting depth (0-based)
31
+
final int depth;
32
+
33
+
/// Whether this post's replies are collapsed
34
+
final bool isCollapsed;
35
+
36
+
/// Callback when collapse toggle is tapped
37
+
final VoidCallback onToggleCollapse;
38
+
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;
44
+
45
+
@override
46
+
Widget build(BuildContext context, WidgetRef ref) {
47
+
final indent = ThreadLayoutCalculator.calculateIndent(depth);
48
+
final shouldFlatten = ThreadLayoutCalculator.shouldFlattenDepth(depth);
49
+
final hasReplies = post.replies.isNotEmpty;
50
+
final connectorStyle = _determineConnectorStyle(hasReplies, isLastSibling);
51
+
52
+
return Column(
53
+
crossAxisAlignment: CrossAxisAlignment.start,
54
+
children: [
55
+
if (shouldFlatten && depth > 0)
56
+
Padding(
57
+
padding: EdgeInsets.only(left: indent),
58
+
child: DeepThreadIndicator(
59
+
parentHandle: post.parent != null ? post.parent!.post.author.handle : '',
60
+
),
61
+
),
62
+
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
+
],
90
+
),
91
+
),
92
+
],
93
+
),
94
+
],
95
+
);
96
+
}
97
+
98
+
Widget _buildPostCard() {
99
+
if (post.isBlocked) {
100
+
return const BlockedPostCard();
101
+
}
102
+
103
+
if (post.isNotFound) {
104
+
return const NotFoundPostCard();
105
+
}
106
+
107
+
return FeedPostCard(item: post.post.toFeedPost());
108
+
}
109
+
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;
118
+
}
119
+
}
+167
test/src/features/thread/application/thread_collapse_state_test.dart
+167
test/src/features/thread/application/thread_collapse_state_test.dart
···
1
+
import 'package:flutter_test/flutter_test.dart';
2
+
import 'package:lazurite/src/features/thread/application/thread_collapse_state.dart';
3
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+
5
+
void main() {
6
+
group('ThreadCollapseState', () {
7
+
late ProviderContainer container;
8
+
9
+
setUp(() {
10
+
container = ProviderContainer();
11
+
});
12
+
13
+
tearDown(() {
14
+
container.dispose();
15
+
});
16
+
17
+
test('initial state is empty map', () {
18
+
final state = container.read(threadCollapseStateProvider);
19
+
expect(state, isEmpty);
20
+
});
21
+
22
+
group('isCollapsed', () {
23
+
test('returns false for posts not in state', () {
24
+
final notifier = container.read(threadCollapseStateProvider.notifier);
25
+
expect(notifier.isCollapsed('at://user/post/1'), false);
26
+
});
27
+
28
+
test('returns true for collapsed posts', () {
29
+
final notifier = container.read(threadCollapseStateProvider.notifier);
30
+
notifier.collapse('at://user/post/1');
31
+
expect(notifier.isCollapsed('at://user/post/1'), true);
32
+
});
33
+
34
+
test('returns false for expanded posts', () {
35
+
final notifier = container.read(threadCollapseStateProvider.notifier);
36
+
notifier.expand('at://user/post/1');
37
+
expect(notifier.isCollapsed('at://user/post/1'), false);
38
+
});
39
+
});
40
+
41
+
group('toggle', () {
42
+
test('collapses expanded post', () {
43
+
final notifier = container.read(threadCollapseStateProvider.notifier);
44
+
notifier.toggle('at://user/post/1');
45
+
expect(notifier.isCollapsed('at://user/post/1'), true);
46
+
});
47
+
48
+
test('expands collapsed post', () {
49
+
final notifier = container.read(threadCollapseStateProvider.notifier);
50
+
notifier.collapse('at://user/post/1');
51
+
notifier.toggle('at://user/post/1');
52
+
expect(notifier.isCollapsed('at://user/post/1'), false);
53
+
});
54
+
55
+
test('toggles multiple posts independently', () {
56
+
final notifier = container.read(threadCollapseStateProvider.notifier);
57
+
notifier.toggle('at://user/post/1');
58
+
notifier.toggle('at://user/post/2');
59
+
60
+
expect(notifier.isCollapsed('at://user/post/1'), true);
61
+
expect(notifier.isCollapsed('at://user/post/2'), true);
62
+
63
+
notifier.toggle('at://user/post/1');
64
+
expect(notifier.isCollapsed('at://user/post/1'), false);
65
+
expect(notifier.isCollapsed('at://user/post/2'), true);
66
+
});
67
+
});
68
+
69
+
group('collapse', () {
70
+
test('collapses post', () {
71
+
final notifier = container.read(threadCollapseStateProvider.notifier);
72
+
notifier.collapse('at://user/post/1');
73
+
expect(notifier.isCollapsed('at://user/post/1'), true);
74
+
});
75
+
76
+
test('keeps post collapsed when called multiple times', () {
77
+
final notifier = container.read(threadCollapseStateProvider.notifier);
78
+
notifier.collapse('at://user/post/1');
79
+
notifier.collapse('at://user/post/1');
80
+
expect(notifier.isCollapsed('at://user/post/1'), true);
81
+
});
82
+
});
83
+
84
+
group('expand', () {
85
+
test('expands post', () {
86
+
final notifier = container.read(threadCollapseStateProvider.notifier);
87
+
notifier.collapse('at://user/post/1');
88
+
notifier.expand('at://user/post/1');
89
+
expect(notifier.isCollapsed('at://user/post/1'), false);
90
+
});
91
+
92
+
test('keeps post expanded when called multiple times', () {
93
+
final notifier = container.read(threadCollapseStateProvider.notifier);
94
+
notifier.expand('at://user/post/1');
95
+
notifier.expand('at://user/post/1');
96
+
expect(notifier.isCollapsed('at://user/post/1'), false);
97
+
});
98
+
});
99
+
100
+
group('collapseAll', () {
101
+
test('collapses all posts in list', () {
102
+
final notifier = container.read(threadCollapseStateProvider.notifier);
103
+
notifier.collapseAll(['at://user/post/1', 'at://user/post/2', 'at://user/post/3']);
104
+
105
+
expect(notifier.isCollapsed('at://user/post/1'), true);
106
+
expect(notifier.isCollapsed('at://user/post/2'), true);
107
+
expect(notifier.isCollapsed('at://user/post/3'), true);
108
+
});
109
+
110
+
test('preserves state of posts not in list', () {
111
+
final notifier = container.read(threadCollapseStateProvider.notifier);
112
+
notifier.collapse('at://user/post/existing');
113
+
114
+
notifier.collapseAll(['at://user/post/1', 'at://user/post/2']);
115
+
116
+
expect(notifier.isCollapsed('at://user/post/existing'), true);
117
+
expect(notifier.isCollapsed('at://user/post/1'), true);
118
+
expect(notifier.isCollapsed('at://user/post/2'), true);
119
+
});
120
+
121
+
test('handles empty list', () {
122
+
final notifier = container.read(threadCollapseStateProvider.notifier);
123
+
notifier.collapse('at://user/post/1');
124
+
notifier.collapseAll([]);
125
+
126
+
expect(notifier.isCollapsed('at://user/post/1'), true);
127
+
});
128
+
});
129
+
130
+
group('expandAll', () {
131
+
test('clears all collapse state', () {
132
+
final notifier = container.read(threadCollapseStateProvider.notifier);
133
+
notifier.collapseAll(['at://user/post/1', 'at://user/post/2', 'at://user/post/3']);
134
+
135
+
notifier.expandAll();
136
+
137
+
expect(notifier.isCollapsed('at://user/post/1'), false);
138
+
expect(notifier.isCollapsed('at://user/post/2'), false);
139
+
expect(notifier.isCollapsed('at://user/post/3'), false);
140
+
});
141
+
142
+
test('works when state is already empty', () {
143
+
final notifier = container.read(threadCollapseStateProvider.notifier);
144
+
notifier.expandAll();
145
+
146
+
final state = container.read(threadCollapseStateProvider);
147
+
expect(state, isEmpty);
148
+
});
149
+
});
150
+
151
+
test('state updates trigger rebuilds', () {
152
+
var buildCount = 0;
153
+
container.listen(threadCollapseStateProvider, (previous, next) => buildCount++);
154
+
155
+
final notifier = container.read(threadCollapseStateProvider.notifier);
156
+
157
+
notifier.toggle('at://user/post/1');
158
+
expect(buildCount, 1);
159
+
160
+
notifier.collapse('at://user/post/2');
161
+
expect(buildCount, 2);
162
+
163
+
notifier.expandAll();
164
+
expect(buildCount, 3);
165
+
});
166
+
});
167
+
}
+192
test/src/features/thread/domain/thread_layout_calculator_test.dart
+192
test/src/features/thread/domain/thread_layout_calculator_test.dart
···
1
+
import 'package:flutter_test/flutter_test.dart';
2
+
import 'package:lazurite/src/features/thread/domain/thread.dart';
3
+
import 'package:lazurite/src/features/thread/domain/thread_layout_calculator.dart';
4
+
5
+
void main() {
6
+
group('ThreadLayoutCalculator', () {
7
+
group('calculateIndent', () {
8
+
test('returns 0 for depth 0', () {
9
+
expect(ThreadLayoutCalculator.calculateIndent(0), 0.0);
10
+
});
11
+
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);
18
+
});
19
+
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);
24
+
});
25
+
26
+
test('handles negative depth gracefully', () {
27
+
expect(ThreadLayoutCalculator.calculateIndent(-1), 0.0);
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
+
});
61
+
});
62
+
63
+
group('shouldFlattenDepth', () {
64
+
test('returns false for depths within maxDepth', () {
65
+
expect(ThreadLayoutCalculator.shouldFlattenDepth(0), false);
66
+
expect(ThreadLayoutCalculator.shouldFlattenDepth(3), false);
67
+
expect(ThreadLayoutCalculator.shouldFlattenDepth(5), false);
68
+
});
69
+
70
+
test('returns true for depths exceeding maxDepth', () {
71
+
expect(ThreadLayoutCalculator.shouldFlattenDepth(6), true);
72
+
expect(ThreadLayoutCalculator.shouldFlattenDepth(10), true);
73
+
expect(ThreadLayoutCalculator.shouldFlattenDepth(100), true);
74
+
});
75
+
});
76
+
77
+
group('calculateEffectiveDepth', () {
78
+
test('returns actual depth when within bounds', () {
79
+
expect(ThreadLayoutCalculator.calculateEffectiveDepth(0), 0);
80
+
expect(ThreadLayoutCalculator.calculateEffectiveDepth(3), 3);
81
+
expect(ThreadLayoutCalculator.calculateEffectiveDepth(5), 5);
82
+
});
83
+
84
+
test('clamps to maxDepth when exceeding', () {
85
+
expect(ThreadLayoutCalculator.calculateEffectiveDepth(6), ThreadLayoutCalculator.maxDepth);
86
+
expect(
87
+
ThreadLayoutCalculator.calculateEffectiveDepth(10),
88
+
ThreadLayoutCalculator.maxDepth,
89
+
);
90
+
});
91
+
92
+
test('clamps to 0 for negative depths', () {
93
+
expect(ThreadLayoutCalculator.calculateEffectiveDepth(-1), 0);
94
+
});
95
+
});
96
+
97
+
group('getParentContext', () {
98
+
test('returns empty string when no parent', () {
99
+
final post = _createThreadViewPost(handle: 'user1');
100
+
expect(ThreadLayoutCalculator.getParentContext(post), '');
101
+
});
102
+
103
+
test('returns parent handle with @ prefix', () {
104
+
final parent = _createThreadViewPost(handle: 'parentuser');
105
+
final post = _createThreadViewPost(handle: 'user1', parent: parent);
106
+
expect(ThreadLayoutCalculator.getParentContext(post), '@parentuser');
107
+
});
108
+
});
109
+
110
+
group('countAllReplies', () {
111
+
test('returns 0 for post with no replies', () {
112
+
final post = _createThreadViewPost(handle: 'user1');
113
+
expect(ThreadLayoutCalculator.countAllReplies(post), 0);
114
+
});
115
+
116
+
test('counts direct replies', () {
117
+
final post = _createThreadViewPost(
118
+
handle: 'user1',
119
+
replies: [
120
+
_createThreadViewPost(handle: 'reply1'),
121
+
_createThreadViewPost(handle: 'reply2'),
122
+
_createThreadViewPost(handle: 'reply3'),
123
+
],
124
+
);
125
+
expect(ThreadLayoutCalculator.countAllReplies(post), 3);
126
+
});
127
+
128
+
test('counts nested replies recursively', () {
129
+
final post = _createThreadViewPost(
130
+
handle: 'user1',
131
+
replies: [
132
+
_createThreadViewPost(
133
+
handle: 'reply1',
134
+
replies: [
135
+
_createThreadViewPost(handle: 'nested1'),
136
+
_createThreadViewPost(handle: 'nested2'),
137
+
],
138
+
),
139
+
_createThreadViewPost(
140
+
handle: 'reply2',
141
+
replies: [_createThreadViewPost(handle: 'nested3')],
142
+
),
143
+
],
144
+
);
145
+
146
+
expect(ThreadLayoutCalculator.countAllReplies(post), 5);
147
+
});
148
+
149
+
test('counts deeply nested replies', () {
150
+
final deepPost = _createThreadViewPost(
151
+
handle: 'root',
152
+
replies: [
153
+
_createThreadViewPost(
154
+
handle: 'level1',
155
+
replies: [
156
+
_createThreadViewPost(
157
+
handle: 'level2',
158
+
replies: [
159
+
_createThreadViewPost(
160
+
handle: 'level3',
161
+
replies: [_createThreadViewPost(handle: 'level4')],
162
+
),
163
+
],
164
+
),
165
+
],
166
+
),
167
+
],
168
+
);
169
+
170
+
expect(ThreadLayoutCalculator.countAllReplies(deepPost), 4);
171
+
});
172
+
});
173
+
});
174
+
}
175
+
176
+
/// Helper to create test ThreadViewPost instances
177
+
ThreadViewPost _createThreadViewPost({
178
+
required String handle,
179
+
ThreadViewPost? parent,
180
+
List<ThreadViewPost>? replies,
181
+
}) {
182
+
return ThreadViewPost(
183
+
post: ThreadPost(
184
+
uri: 'at://$handle/post',
185
+
cid: 'cid-$handle',
186
+
author: ThreadAuthor(did: 'did:$handle', handle: handle),
187
+
record: {'text': 'Test post'},
188
+
),
189
+
parent: parent,
190
+
replies: replies ?? [],
191
+
);
192
+
}
+125
test/src/features/thread/presentation/widgets/collapse_toggle_test.dart
+125
test/src/features/thread/presentation/widgets/collapse_toggle_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/collapse_toggle.dart';
4
+
5
+
void main() {
6
+
group('CollapseToggle', () {
7
+
testWidgets('renders with expanded state', (tester) async {
8
+
var tapped = false;
9
+
10
+
await tester.pumpWidget(
11
+
MaterialApp(
12
+
home: Scaffold(body: CollapseToggle(isCollapsed: false, onTap: () => tapped = true)),
13
+
),
14
+
);
15
+
16
+
expect(find.byType(CollapseToggle), findsOneWidget);
17
+
expect(find.byType(Icon), findsOneWidget);
18
+
expect(find.byIcon(Icons.play_arrow), findsOneWidget);
19
+
20
+
await tester.tap(find.byType(CollapseToggle));
21
+
await tester.pump();
22
+
23
+
expect(tapped, true);
24
+
});
25
+
26
+
testWidgets('renders with collapsed state', (tester) async {
27
+
await tester.pumpWidget(
28
+
MaterialApp(
29
+
home: Scaffold(body: CollapseToggle(isCollapsed: true, onTap: () {})),
30
+
),
31
+
);
32
+
33
+
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);
47
+
});
48
+
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 {
79
+
var isCollapsed = false;
80
+
81
+
await tester.pumpWidget(
82
+
MaterialApp(
83
+
home: Scaffold(
84
+
body: StatefulBuilder(
85
+
builder: (context, setState) {
86
+
return CollapseToggle(
87
+
isCollapsed: isCollapsed,
88
+
onTap: () => setState(() => isCollapsed = !isCollapsed),
89
+
);
90
+
},
91
+
),
92
+
),
93
+
),
94
+
);
95
+
96
+
var rotation = tester.widget<AnimatedRotation>(find.byType(AnimatedRotation));
97
+
expect(rotation.turns, 0);
98
+
99
+
await tester.tap(find.byType(CollapseToggle));
100
+
await tester.pump();
101
+
102
+
rotation = tester.widget<AnimatedRotation>(find.byType(AnimatedRotation));
103
+
expect(rotation.turns, -0.25);
104
+
});
105
+
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
+
),
118
+
),
119
+
);
120
+
121
+
expect(find.text(count.toString()), findsOneWidget);
122
+
}
123
+
});
124
+
});
125
+
}
+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
+
}
+187
test/src/features/thread/presentation/widgets/thread_reply_item_test.dart
+187
test/src/features/thread/presentation/widgets/thread_reply_item_test.dart
···
1
+
import 'package:flutter/material.dart';
2
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+
import 'package:flutter_test/flutter_test.dart';
4
+
import 'package:lazurite/src/features/thread/domain/thread.dart';
5
+
import 'package:lazurite/src/features/thread/presentation/widgets/thread_reply_item.dart';
6
+
7
+
void main() {
8
+
group('ThreadReplyItem', () {
9
+
testWidgets('renders post at depth 0', (tester) async {
10
+
final post = _createThreadViewPost(handle: 'user1');
11
+
12
+
await tester.pumpWidget(
13
+
ProviderScope(
14
+
child: MaterialApp(
15
+
home: Scaffold(
16
+
body: ThreadReplyItem(
17
+
post: post,
18
+
depth: 0,
19
+
isCollapsed: false,
20
+
onToggleCollapse: () {},
21
+
),
22
+
),
23
+
),
24
+
),
25
+
);
26
+
27
+
expect(find.byType(ThreadReplyItem), findsOneWidget);
28
+
});
29
+
30
+
testWidgets('renders post with replies shows collapse toggle', (tester) async {
31
+
final post = _createThreadViewPost(
32
+
handle: 'user1',
33
+
replies: [
34
+
_createThreadViewPost(handle: 'reply1'),
35
+
_createThreadViewPost(handle: 'reply2'),
36
+
],
37
+
);
38
+
39
+
await tester.pumpWidget(
40
+
ProviderScope(
41
+
child: MaterialApp(
42
+
home: Scaffold(
43
+
body: ThreadReplyItem(
44
+
post: post,
45
+
depth: 0,
46
+
isCollapsed: false,
47
+
onToggleCollapse: () {},
48
+
),
49
+
),
50
+
),
51
+
),
52
+
);
53
+
54
+
expect(find.byType(ThreadReplyItem), findsOneWidget);
55
+
});
56
+
57
+
testWidgets('renders post without replies shows no toggle', (tester) async {
58
+
final post = _createThreadViewPost(handle: 'user1');
59
+
60
+
await tester.pumpWidget(
61
+
ProviderScope(
62
+
child: MaterialApp(
63
+
home: Scaffold(
64
+
body: ThreadReplyItem(
65
+
post: post,
66
+
depth: 0,
67
+
isCollapsed: false,
68
+
onToggleCollapse: () {},
69
+
),
70
+
),
71
+
),
72
+
),
73
+
);
74
+
75
+
expect(find.byType(ThreadReplyItem), findsOneWidget);
76
+
});
77
+
78
+
testWidgets('renders at different depth levels', (tester) async {
79
+
for (var depth = 0; depth <= 5; depth++) {
80
+
final post = _createThreadViewPost(handle: 'user$depth');
81
+
82
+
await tester.pumpWidget(
83
+
ProviderScope(
84
+
child: MaterialApp(
85
+
home: Scaffold(
86
+
body: ThreadReplyItem(
87
+
post: post,
88
+
depth: depth,
89
+
isCollapsed: false,
90
+
onToggleCollapse: () {},
91
+
),
92
+
),
93
+
),
94
+
),
95
+
);
96
+
97
+
final item = tester.widget<ThreadReplyItem>(find.byType(ThreadReplyItem));
98
+
expect(item.depth, depth);
99
+
}
100
+
});
101
+
102
+
testWidgets('shows deep thread indicator when depth exceeds max', (tester) async {
103
+
final parent = _createThreadViewPost(handle: 'parent');
104
+
final post = _createThreadViewPost(handle: 'user1', parent: parent);
105
+
106
+
await tester.pumpWidget(
107
+
ProviderScope(
108
+
child: MaterialApp(
109
+
home: Scaffold(
110
+
body: ThreadReplyItem(
111
+
post: post,
112
+
depth: 6, // Exceeds MAX_DEPTH of 5
113
+
isCollapsed: false,
114
+
onToggleCollapse: () {},
115
+
),
116
+
),
117
+
),
118
+
),
119
+
);
120
+
121
+
expect(find.text('Replying to @parent'), findsOneWidget);
122
+
});
123
+
124
+
testWidgets('renders blocked post card', (tester) async {
125
+
final post = _createThreadViewPost(handle: 'user1', isBlocked: true);
126
+
127
+
await tester.pumpWidget(
128
+
ProviderScope(
129
+
child: MaterialApp(
130
+
home: Scaffold(
131
+
body: ThreadReplyItem(
132
+
post: post,
133
+
depth: 0,
134
+
isCollapsed: false,
135
+
onToggleCollapse: () {},
136
+
),
137
+
),
138
+
),
139
+
),
140
+
);
141
+
142
+
expect(find.byType(ThreadReplyItem), findsOneWidget);
143
+
});
144
+
145
+
testWidgets('renders not found post card', (tester) async {
146
+
final post = _createThreadViewPost(handle: 'user1', isNotFound: true);
147
+
148
+
await tester.pumpWidget(
149
+
ProviderScope(
150
+
child: MaterialApp(
151
+
home: Scaffold(
152
+
body: ThreadReplyItem(
153
+
post: post,
154
+
depth: 0,
155
+
isCollapsed: false,
156
+
onToggleCollapse: () {},
157
+
),
158
+
),
159
+
),
160
+
),
161
+
);
162
+
163
+
expect(find.byType(ThreadReplyItem), findsOneWidget);
164
+
});
165
+
});
166
+
}
167
+
168
+
ThreadViewPost _createThreadViewPost({
169
+
required String handle,
170
+
ThreadViewPost? parent,
171
+
List<ThreadViewPost>? replies,
172
+
bool isBlocked = false,
173
+
bool isNotFound = false,
174
+
}) {
175
+
return ThreadViewPost(
176
+
post: ThreadPost(
177
+
uri: 'at://$handle/post',
178
+
cid: 'cid-$handle',
179
+
author: ThreadAuthor(did: 'did:$handle', handle: handle),
180
+
record: {'text': 'Test post from $handle'},
181
+
),
182
+
parent: parent,
183
+
replies: replies ?? [],
184
+
isBlocked: isBlocked,
185
+
isNotFound: isNotFound,
186
+
);
187
+
}