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