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