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