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