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