mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter

feat: collapsible threads

+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
··· 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
··· 8 8 9 9 import '../infrastructure/thread_repository.dart'; 10 10 11 + export 'thread_collapse_state.dart'; 12 + 11 13 part 'thread_providers.g.dart'; 12 14 13 15 @riverpod
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + }