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