1import 'package:cached_network_image/cached_network_image.dart'; 2import 'package:flutter/foundation.dart'; 3import 'package:flutter/material.dart'; 4import 'package:go_router/go_router.dart'; 5import 'package:provider/provider.dart'; 6 7import '../constants/app_colors.dart'; 8import '../models/post.dart'; 9import '../services/streamable_service.dart'; 10import '../utils/community_handle_utils.dart'; 11import '../utils/date_time_utils.dart'; 12import 'external_link_bar.dart'; 13import 'fullscreen_video_player.dart'; 14import 'post_card_actions.dart'; 15 16/// Post card widget for displaying feed posts 17/// 18/// Displays a post with: 19/// - Community and author information 20/// - Post title and text content 21/// - External embed (link preview with image) 22/// - Action buttons (share, comment, like) 23/// 24/// The [currentTime] parameter allows passing the current time for 25/// time-ago calculations, enabling: 26/// - Periodic updates of time strings 27/// - Deterministic testing without DateTime.now() 28class PostCard extends StatelessWidget { 29 const PostCard({ 30 required this.post, 31 this.currentTime, 32 this.showCommentButton = true, 33 this.disableNavigation = false, 34 this.showActions = true, 35 this.showHeader = true, 36 this.showBorder = true, 37 super.key, 38 }); 39 40 final FeedViewPost post; 41 final DateTime? currentTime; 42 final bool showCommentButton; 43 final bool disableNavigation; 44 final bool showActions; 45 final bool showHeader; 46 final bool showBorder; 47 48 /// Check if this post should be clickable 49 /// Only text posts (no embeds or non-video/link embeds) are 50 /// clickable 51 bool get _isClickable { 52 // If navigation is explicitly disabled (e.g., on detail screen), 53 // not clickable 54 if (disableNavigation) { 55 return false; 56 } 57 58 final embed = post.post.embed; 59 60 // If no embed, it's a text-only post - clickable 61 if (embed == null) { 62 return true; 63 } 64 65 // If embed exists, check if it's a video or link type 66 final external = embed.external; 67 if (external == null) { 68 return true; // No external embed, clickable 69 } 70 71 final embedType = external.embedType; 72 73 // Video and video-stream posts should NOT be clickable (they have 74 // their own tap handling) 75 if (embedType == 'video' || embedType == 'video-stream') { 76 return false; 77 } 78 79 // Link embeds should NOT be clickable (they have their own link handling) 80 if (embedType == 'link') { 81 return false; 82 } 83 84 // All other types are clickable 85 return true; 86 } 87 88 void _navigateToDetail(BuildContext context) { 89 // Navigate to post detail screen 90 // Use URI-encoded version of the post URI for the URL path 91 // Pass the full post object via extras 92 final encodedUri = Uri.encodeComponent(post.post.uri); 93 context.push('/post/$encodedUri', extra: post); 94 } 95 96 @override 97 Widget build(BuildContext context) { 98 return Container( 99 margin: EdgeInsets.only(bottom: showHeader ? 8 : 0), 100 decoration: BoxDecoration( 101 color: AppColors.background, 102 border: 103 showBorder 104 ? const Border(bottom: BorderSide(color: AppColors.border)) 105 : null, 106 ), 107 child: Padding( 108 padding: EdgeInsets.fromLTRB(16, showHeader ? 4 : 12, 16, 1), 109 child: Column( 110 crossAxisAlignment: CrossAxisAlignment.start, 111 children: [ 112 // Community and author info 113 if (showHeader) ...[ 114 Row( 115 children: [ 116 // Community avatar 117 _buildCommunityAvatar(post.post.community), 118 const SizedBox(width: 8), 119 Expanded( 120 child: Column( 121 crossAxisAlignment: CrossAxisAlignment.start, 122 children: [ 123 // Community handle with styled parts 124 _buildCommunityHandle(post.post.community), 125 // Author handle 126 Text( 127 '@${post.post.author.handle}', 128 style: const TextStyle( 129 color: AppColors.textSecondary, 130 fontSize: 12, 131 ), 132 ), 133 ], 134 ), 135 ), 136 // Time ago 137 Text( 138 DateTimeUtils.formatTimeAgo( 139 post.post.createdAt, 140 currentTime: currentTime, 141 ), 142 style: TextStyle( 143 color: AppColors.textPrimary.withValues(alpha: 0.5), 144 fontSize: 14, 145 ), 146 ), 147 ], 148 ), 149 const SizedBox(height: 8), 150 ], 151 152 // Wrap content in InkWell if clickable (text-only posts) 153 if (_isClickable) 154 InkWell( 155 onTap: () => _navigateToDetail(context), 156 child: Column( 157 crossAxisAlignment: CrossAxisAlignment.start, 158 children: [ 159 // Post title 160 if (post.post.title != null) ...[ 161 Text( 162 post.post.title!, 163 style: const TextStyle( 164 color: AppColors.textPrimary, 165 fontSize: 16, 166 fontWeight: FontWeight.w400, 167 ), 168 ), 169 ], 170 171 // Spacing after title (only if we have text) 172 if (post.post.title != null && post.post.text.isNotEmpty) 173 const SizedBox(height: 8), 174 175 // Post text body preview 176 if (post.post.text.isNotEmpty) ...[ 177 Container( 178 padding: const EdgeInsets.all(10), 179 decoration: BoxDecoration( 180 color: AppColors.backgroundSecondary, 181 borderRadius: BorderRadius.circular(8), 182 ), 183 child: Text( 184 post.post.text, 185 style: TextStyle( 186 color: AppColors.textPrimary.withValues(alpha: 0.7), 187 fontSize: 13, 188 height: 1.4, 189 ), 190 maxLines: 5, 191 overflow: TextOverflow.ellipsis, 192 ), 193 ), 194 ], 195 ], 196 ), 197 ) 198 else 199 // Non-clickable content (video/link posts) 200 Column( 201 crossAxisAlignment: CrossAxisAlignment.start, 202 children: [ 203 // Post title 204 if (post.post.title != null) ...[ 205 Text( 206 post.post.title!, 207 style: const TextStyle( 208 color: AppColors.textPrimary, 209 fontSize: 16, 210 fontWeight: FontWeight.w400, 211 ), 212 ), 213 ], 214 215 // Spacing after title (only if we have content below) 216 if (post.post.title != null && 217 (post.post.embed?.external != null || 218 post.post.text.isNotEmpty)) 219 const SizedBox(height: 8), 220 221 // Embed (link preview) 222 if (post.post.embed?.external != null) ...[ 223 _EmbedCard( 224 embed: post.post.embed!.external!, 225 streamableService: context.read<StreamableService>(), 226 ), 227 const SizedBox(height: 8), 228 ], 229 230 // Post text body preview 231 if (post.post.text.isNotEmpty) ...[ 232 Container( 233 padding: const EdgeInsets.all(10), 234 decoration: BoxDecoration( 235 color: AppColors.backgroundSecondary, 236 borderRadius: BorderRadius.circular(8), 237 ), 238 child: Text( 239 post.post.text, 240 style: TextStyle( 241 color: AppColors.textPrimary.withValues(alpha: 0.7), 242 fontSize: 13, 243 height: 1.4, 244 ), 245 maxLines: 5, 246 overflow: TextOverflow.ellipsis, 247 ), 248 ), 249 ], 250 ], 251 ), 252 253 // External link (if present) 254 if (post.post.embed?.external != null) ...[ 255 const SizedBox(height: 8), 256 ExternalLinkBar(embed: post.post.embed!.external!), 257 ], 258 259 // Reduced spacing before action buttons 260 if (showActions) const SizedBox(height: 4), 261 262 // Action buttons row 263 if (showActions) 264 PostCardActions(post: post, showCommentButton: showCommentButton), 265 ], 266 ), 267 ), 268 ); 269 } 270 271 /// Builds the community handle with styled parts (name + instance) 272 Widget _buildCommunityHandle(CommunityRef community) { 273 final displayHandle = 274 CommunityHandleUtils.formatHandleForDisplay(community.handle)!; 275 276 // Split the handle into community name and instance 277 // Format: !gaming@coves.social 278 final atIndex = displayHandle.indexOf('@'); 279 final communityPart = displayHandle.substring(0, atIndex); 280 final instancePart = displayHandle.substring(atIndex); 281 282 return Text.rich( 283 TextSpan( 284 children: [ 285 TextSpan( 286 text: communityPart, 287 style: const TextStyle( 288 color: AppColors.communityName, 289 fontSize: 14, 290 ), 291 ), 292 TextSpan( 293 text: instancePart, 294 style: TextStyle( 295 color: AppColors.textSecondary.withValues(alpha: 0.6), 296 fontSize: 14, 297 ), 298 ), 299 ], 300 ), 301 ); 302 } 303 304 /// Builds the community avatar widget 305 Widget _buildCommunityAvatar(CommunityRef community) { 306 if (community.avatar != null && community.avatar!.isNotEmpty) { 307 // Show real community avatar 308 return ClipRRect( 309 borderRadius: BorderRadius.circular(4), 310 child: CachedNetworkImage( 311 imageUrl: community.avatar!, 312 width: 24, 313 height: 24, 314 fit: BoxFit.cover, 315 placeholder: (context, url) => _buildFallbackAvatar(community), 316 errorWidget: (context, url, error) => _buildFallbackAvatar(community), 317 ), 318 ); 319 } 320 321 // Fallback to letter placeholder 322 return _buildFallbackAvatar(community); 323 } 324 325 /// Builds a fallback avatar with the first letter of community name 326 Widget _buildFallbackAvatar(CommunityRef community) { 327 return Container( 328 width: 24, 329 height: 24, 330 decoration: BoxDecoration( 331 color: AppColors.primary, 332 borderRadius: BorderRadius.circular(4), 333 ), 334 child: Center( 335 child: Text( 336 community.name[0].toUpperCase(), 337 style: const TextStyle( 338 color: AppColors.textPrimary, 339 fontSize: 12, 340 fontWeight: FontWeight.bold, 341 ), 342 ), 343 ), 344 ); 345 } 346} 347 348/// Embed card widget for displaying link previews 349/// 350/// Shows a thumbnail image for external embeds with loading and error states. 351/// For video embeds (Streamable), displays a play button overlay and opens 352/// a video player dialog when tapped. 353class _EmbedCard extends StatefulWidget { 354 const _EmbedCard({required this.embed, required this.streamableService}); 355 356 final ExternalEmbed embed; 357 final StreamableService streamableService; 358 359 @override 360 State<_EmbedCard> createState() => _EmbedCardState(); 361} 362 363class _EmbedCardState extends State<_EmbedCard> { 364 bool _isLoadingVideo = false; 365 366 /// Checks if this embed is a video 367 bool get _isVideo { 368 final embedType = widget.embed.embedType; 369 return embedType == 'video' || embedType == 'video-stream'; 370 } 371 372 /// Checks if this is a Streamable video 373 bool get _isStreamableVideo { 374 return _isVideo && widget.embed.provider?.toLowerCase() == 'streamable'; 375 } 376 377 /// Shows the video player in fullscreen with swipe-to-dismiss 378 Future<void> _showVideoPlayer(BuildContext context) async { 379 // Capture context-dependent objects before async gap 380 final messenger = ScaffoldMessenger.of(context); 381 final navigator = Navigator.of(context); 382 383 setState(() { 384 _isLoadingVideo = true; 385 }); 386 387 try { 388 // Fetch the MP4 URL from Streamable using the injected service 389 final videoUrl = await widget.streamableService.getVideoUrl( 390 widget.embed.uri, 391 ); 392 393 if (!mounted) { 394 return; 395 } 396 397 if (videoUrl == null) { 398 // Show error if we couldn't get the video URL 399 messenger.showSnackBar( 400 SnackBar( 401 content: Text( 402 'Failed to load video', 403 style: TextStyle( 404 color: AppColors.textPrimary.withValues(alpha: 0.9), 405 ), 406 ), 407 backgroundColor: AppColors.backgroundSecondary, 408 ), 409 ); 410 return; 411 } 412 413 // Navigate to fullscreen video player 414 await navigator.push<void>( 415 MaterialPageRoute( 416 builder: (context) => FullscreenVideoPlayer(videoUrl: videoUrl), 417 fullscreenDialog: true, 418 ), 419 ); 420 } finally { 421 if (mounted) { 422 setState(() { 423 _isLoadingVideo = false; 424 }); 425 } 426 } 427 } 428 429 @override 430 Widget build(BuildContext context) { 431 // Only show image if thumbnail exists 432 if (widget.embed.thumb == null) { 433 return const SizedBox.shrink(); 434 } 435 436 // Build the thumbnail image 437 final thumbnailWidget = Container( 438 decoration: BoxDecoration( 439 borderRadius: BorderRadius.circular(8), 440 border: Border.all(color: AppColors.border), 441 ), 442 clipBehavior: Clip.antiAlias, 443 child: CachedNetworkImage( 444 imageUrl: widget.embed.thumb!, 445 width: double.infinity, 446 height: 180, 447 fit: BoxFit.cover, 448 placeholder: 449 (context, url) => Container( 450 width: double.infinity, 451 height: 180, 452 color: AppColors.background, 453 child: const Center( 454 child: CircularProgressIndicator( 455 color: AppColors.loadingIndicator, 456 ), 457 ), 458 ), 459 errorWidget: (context, url, error) { 460 if (kDebugMode) { 461 debugPrint('❌ Image load error: $error'); 462 debugPrint('URL: $url'); 463 } 464 return Container( 465 width: double.infinity, 466 height: 180, 467 color: AppColors.background, 468 child: const Icon( 469 Icons.broken_image, 470 color: AppColors.loadingIndicator, 471 size: 48, 472 ), 473 ); 474 }, 475 ), 476 ); 477 478 // If this is a Streamable video, add play button overlay and tap handler 479 if (_isStreamableVideo) { 480 return GestureDetector( 481 onTap: _isLoadingVideo ? null : () => _showVideoPlayer(context), 482 child: Stack( 483 alignment: Alignment.center, 484 children: [ 485 thumbnailWidget, 486 // Semi-transparent play button or loading indicator overlay 487 Container( 488 width: 64, 489 height: 64, 490 decoration: BoxDecoration( 491 color: AppColors.background.withValues(alpha: 0.7), 492 shape: BoxShape.circle, 493 ), 494 child: 495 _isLoadingVideo 496 ? const CircularProgressIndicator( 497 color: AppColors.loadingIndicator, 498 ) 499 : const Icon( 500 Icons.play_arrow, 501 color: AppColors.textPrimary, 502 size: 48, 503 ), 504 ), 505 ], 506 ), 507 ); 508 } 509 510 // For non-video embeds, just return the thumbnail 511 return thumbnailWidget; 512 } 513}