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