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
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}