Main coves client
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}