1import 'package:cached_network_image/cached_network_image.dart'; 2import 'package:flutter/foundation.dart'; 3import 'package:flutter/material.dart'; 4import 'package:flutter/services.dart'; 5import 'package:provider/provider.dart'; 6 7import '../constants/app_colors.dart'; 8import '../models/post.dart'; 9import '../providers/auth_provider.dart'; 10import '../providers/vote_provider.dart'; 11import '../utils/date_time_utils.dart'; 12import 'icons/animated_heart_icon.dart'; 13import 'icons/reply_icon.dart'; 14import 'icons/share_icon.dart'; 15import 'sign_in_dialog.dart'; 16 17/// Post card widget for displaying feed posts 18/// 19/// Displays a post with: 20/// - Community and author information 21/// - Post title and text content 22/// - External embed (link preview with image) 23/// - Action buttons (share, comment, like) 24/// 25/// The [currentTime] parameter allows passing the current time for 26/// time-ago calculations, enabling: 27/// - Periodic updates of time strings 28/// - Deterministic testing without DateTime.now() 29class PostCard extends StatelessWidget { 30 const PostCard({required this.post, this.currentTime, super.key}); 31 32 final FeedViewPost post; 33 final DateTime? currentTime; 34 35 @override 36 Widget build(BuildContext context) { 37 return Container( 38 margin: const EdgeInsets.only(bottom: 8), 39 decoration: const BoxDecoration( 40 color: AppColors.background, 41 border: Border(bottom: BorderSide(color: AppColors.border)), 42 ), 43 child: Padding( 44 padding: const EdgeInsets.fromLTRB(16, 4, 16, 1), 45 child: Column( 46 crossAxisAlignment: CrossAxisAlignment.start, 47 children: [ 48 // Community and author info 49 Row( 50 children: [ 51 // Community avatar placeholder 52 Container( 53 width: 24, 54 height: 24, 55 decoration: BoxDecoration( 56 color: AppColors.primary, 57 borderRadius: BorderRadius.circular(4), 58 ), 59 child: Center( 60 child: Text( 61 post.post.community.name[0].toUpperCase(), 62 style: const TextStyle( 63 color: AppColors.textPrimary, 64 fontSize: 12, 65 fontWeight: FontWeight.bold, 66 ), 67 ), 68 ), 69 ), 70 const SizedBox(width: 8), 71 Expanded( 72 child: Column( 73 crossAxisAlignment: CrossAxisAlignment.start, 74 children: [ 75 Text( 76 'c/${post.post.community.name}', 77 style: const TextStyle( 78 color: AppColors.textPrimary, 79 fontSize: 14, 80 fontWeight: FontWeight.bold, 81 ), 82 ), 83 Text( 84 '@${post.post.author.handle}', 85 style: const TextStyle( 86 color: AppColors.textSecondary, 87 fontSize: 12, 88 ), 89 ), 90 ], 91 ), 92 ), 93 // Time ago 94 Text( 95 DateTimeUtils.formatTimeAgo( 96 post.post.createdAt, 97 currentTime: currentTime, 98 ), 99 style: TextStyle( 100 color: AppColors.textPrimary.withValues(alpha: 0.5), 101 fontSize: 14, 102 ), 103 ), 104 ], 105 ), 106 const SizedBox(height: 8), 107 108 // Post title 109 if (post.post.title != null) ...[ 110 Text( 111 post.post.title!, 112 style: const TextStyle( 113 color: AppColors.textPrimary, 114 fontSize: 16, 115 fontWeight: FontWeight.w400, 116 ), 117 ), 118 ], 119 120 // Spacing after title (only if we have content below) 121 if (post.post.title != null && 122 (post.post.embed?.external != null || 123 post.post.text.isNotEmpty)) 124 const SizedBox(height: 8), 125 126 // Embed (link preview) 127 if (post.post.embed?.external != null) ...[ 128 _EmbedCard(embed: post.post.embed!.external!), 129 const SizedBox(height: 8), 130 ], 131 132 // Post text body preview 133 if (post.post.text.isNotEmpty) ...[ 134 Container( 135 padding: const EdgeInsets.all(10), 136 decoration: BoxDecoration( 137 color: AppColors.backgroundSecondary, 138 borderRadius: BorderRadius.circular(8), 139 ), 140 child: Text( 141 post.post.text, 142 style: TextStyle( 143 color: AppColors.textPrimary.withValues(alpha: 0.7), 144 fontSize: 13, 145 height: 1.4, 146 ), 147 maxLines: 5, 148 overflow: TextOverflow.ellipsis, 149 ), 150 ), 151 ], 152 153 // Reduced spacing before action buttons 154 const SizedBox(height: 4), 155 156 // Action buttons row 157 Row( 158 mainAxisAlignment: MainAxisAlignment.end, 159 children: [ 160 // Share button 161 InkWell( 162 onTap: () { 163 // TODO: Handle share interaction with backend 164 if (kDebugMode) { 165 debugPrint('Share button tapped for post'); 166 } 167 }, 168 child: Padding( 169 // Increased padding for better touch targets 170 padding: const EdgeInsets.symmetric( 171 horizontal: 12, 172 vertical: 10, 173 ), 174 child: ShareIcon( 175 color: AppColors.textPrimary.withValues(alpha: 0.6), 176 ), 177 ), 178 ), 179 const SizedBox(width: 8), 180 181 // Comment button 182 InkWell( 183 onTap: () { 184 // TODO: Navigate to post detail/comments screen 185 if (kDebugMode) { 186 debugPrint('Comment button tapped for post'); 187 } 188 }, 189 child: Padding( 190 // Increased padding for better touch targets 191 padding: const EdgeInsets.symmetric( 192 horizontal: 12, 193 vertical: 10, 194 ), 195 child: Row( 196 mainAxisSize: MainAxisSize.min, 197 children: [ 198 ReplyIcon( 199 color: AppColors.textPrimary.withValues(alpha: 0.6), 200 ), 201 const SizedBox(width: 5), 202 Text( 203 DateTimeUtils.formatCount( 204 post.post.stats.commentCount, 205 ), 206 style: TextStyle( 207 color: AppColors.textPrimary.withValues(alpha: 0.6), 208 fontSize: 13, 209 ), 210 ), 211 ], 212 ), 213 ), 214 ), 215 const SizedBox(width: 8), 216 217 // Heart button 218 Consumer<VoteProvider>( 219 builder: (context, voteProvider, child) { 220 final isLiked = voteProvider.isLiked(post.post.uri); 221 final adjustedScore = voteProvider.getAdjustedScore( 222 post.post.uri, 223 post.post.stats.score, 224 ); 225 226 return InkWell( 227 onTap: () async { 228 // Check authentication 229 final authProvider = context.read<AuthProvider>(); 230 if (!authProvider.isAuthenticated) { 231 // Show sign-in dialog 232 final shouldSignIn = await SignInDialog.show( 233 context, 234 message: 'You need to sign in to like posts.', 235 ); 236 237 if ((shouldSignIn ?? false) && context.mounted) { 238 // TODO: Navigate to sign-in screen 239 if (kDebugMode) { 240 debugPrint('Navigate to sign-in screen'); 241 } 242 } 243 return; 244 } 245 246 // Light haptic feedback on both like and unlike 247 await HapticFeedback.lightImpact(); 248 249 // Toggle vote with optimistic update 250 try { 251 await voteProvider.toggleVote( 252 postUri: post.post.uri, 253 postCid: post.post.cid, 254 ); 255 } on Exception catch (e) { 256 if (kDebugMode) { 257 debugPrint('Failed to toggle vote: $e'); 258 } 259 // TODO: Show error snackbar 260 } 261 }, 262 child: Padding( 263 // Increased padding for better touch targets 264 padding: const EdgeInsets.symmetric( 265 horizontal: 12, 266 vertical: 10, 267 ), 268 child: Row( 269 mainAxisSize: MainAxisSize.min, 270 children: [ 271 AnimatedHeartIcon( 272 isLiked: isLiked, 273 color: AppColors.textPrimary 274 .withValues(alpha: 0.6), 275 likedColor: const Color(0xFFFF0033), 276 ), 277 const SizedBox(width: 5), 278 Text( 279 DateTimeUtils.formatCount(adjustedScore), 280 style: TextStyle( 281 color: AppColors.textPrimary 282 .withValues(alpha: 0.6), 283 fontSize: 13, 284 ), 285 ), 286 ], 287 ), 288 ), 289 ); 290 }, 291 ), 292 ], 293 ), 294 ], 295 ), 296 ), 297 ); 298 } 299} 300 301/// Embed card widget for displaying link previews 302/// 303/// Shows a thumbnail image for external embeds with loading and error states. 304class _EmbedCard extends StatelessWidget { 305 const _EmbedCard({required this.embed}); 306 307 final ExternalEmbed embed; 308 309 @override 310 Widget build(BuildContext context) { 311 // Only show image if thumbnail exists 312 if (embed.thumb == null) { 313 return const SizedBox.shrink(); 314 } 315 316 return Container( 317 decoration: BoxDecoration( 318 borderRadius: BorderRadius.circular(8), 319 border: Border.all(color: AppColors.border), 320 ), 321 clipBehavior: Clip.antiAlias, 322 child: CachedNetworkImage( 323 imageUrl: embed.thumb!, 324 width: double.infinity, 325 height: 180, 326 fit: BoxFit.cover, 327 placeholder: 328 (context, url) => Container( 329 width: double.infinity, 330 height: 180, 331 color: AppColors.background, 332 child: const Center( 333 child: CircularProgressIndicator( 334 color: AppColors.loadingIndicator, 335 ), 336 ), 337 ), 338 errorWidget: (context, url, error) { 339 if (kDebugMode) { 340 debugPrint('❌ Image load error: $error'); 341 debugPrint('URL: $url'); 342 } 343 return Container( 344 width: double.infinity, 345 height: 180, 346 color: AppColors.background, 347 child: const Icon( 348 Icons.broken_image, 349 color: AppColors.loadingIndicator, 350 size: 48, 351 ), 352 ); 353 }, 354 ), 355 ); 356 } 357}