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