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 222 return InkWell( 223 onTap: () async { 224 // Check authentication 225 final authProvider = context.read<AuthProvider>(); 226 if (!authProvider.isAuthenticated) { 227 // Show sign-in dialog 228 final shouldSignIn = await SignInDialog.show( 229 context, 230 message: 'You need to sign in to like posts.', 231 ); 232 233 if ((shouldSignIn ?? false) && context.mounted) { 234 // TODO: Navigate to sign-in screen 235 if (kDebugMode) { 236 debugPrint('Navigate to sign-in screen'); 237 } 238 } 239 return; 240 } 241 242 // Light haptic feedback on both like and unlike 243 await HapticFeedback.lightImpact(); 244 245 // Toggle vote with optimistic update 246 try { 247 await voteProvider.toggleVote( 248 postUri: post.post.uri, 249 postCid: post.post.cid, 250 ); 251 } on Exception catch (e) { 252 if (kDebugMode) { 253 debugPrint('Failed to toggle vote: $e'); 254 } 255 // TODO: Show error snackbar 256 } 257 }, 258 child: Padding( 259 // Increased padding for better touch targets 260 padding: const EdgeInsets.symmetric( 261 horizontal: 12, 262 vertical: 10, 263 ), 264 child: Row( 265 mainAxisSize: MainAxisSize.min, 266 children: [ 267 AnimatedHeartIcon( 268 isLiked: isLiked, 269 color: AppColors.textPrimary 270 .withValues(alpha: 0.6), 271 likedColor: const Color(0xFFFF0033), 272 ), 273 const SizedBox(width: 5), 274 Text( 275 DateTimeUtils.formatCount(post.post.stats.score), 276 style: TextStyle( 277 color: AppColors.textPrimary 278 .withValues(alpha: 0.6), 279 fontSize: 13, 280 ), 281 ), 282 ], 283 ), 284 ), 285 ); 286 }, 287 ), 288 ], 289 ), 290 ], 291 ), 292 ), 293 ); 294 } 295} 296 297/// Embed card widget for displaying link previews 298/// 299/// Shows a thumbnail image for external embeds with loading and error states. 300class _EmbedCard extends StatelessWidget { 301 const _EmbedCard({required this.embed}); 302 303 final ExternalEmbed embed; 304 305 @override 306 Widget build(BuildContext context) { 307 // Only show image if thumbnail exists 308 if (embed.thumb == null) { 309 return const SizedBox.shrink(); 310 } 311 312 return Container( 313 decoration: BoxDecoration( 314 borderRadius: BorderRadius.circular(8), 315 border: Border.all(color: AppColors.border), 316 ), 317 clipBehavior: Clip.antiAlias, 318 child: CachedNetworkImage( 319 imageUrl: embed.thumb!, 320 width: double.infinity, 321 height: 180, 322 fit: BoxFit.cover, 323 placeholder: 324 (context, url) => Container( 325 width: double.infinity, 326 height: 180, 327 color: AppColors.background, 328 child: const Center( 329 child: CircularProgressIndicator( 330 color: AppColors.loadingIndicator, 331 ), 332 ), 333 ), 334 errorWidget: (context, url, error) { 335 if (kDebugMode) { 336 debugPrint('❌ Image load error: $error'); 337 debugPrint('URL: $url'); 338 } 339 return Container( 340 width: double.infinity, 341 height: 180, 342 color: AppColors.background, 343 child: const Icon( 344 Icons.broken_image, 345 color: AppColors.loadingIndicator, 346 size: 48, 347 ), 348 ); 349 }, 350 ), 351 ); 352 } 353}