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

feat: thread collapsing adjustment

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