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