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