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