Main coves client
1import 'package:cached_network_image/cached_network_image.dart';
2import 'package:flutter/material.dart';
3import 'package:google_fonts/google_fonts.dart';
4import 'package:provider/provider.dart';
5
6import '../constants/app_colors.dart';
7import '../models/post.dart';
8import '../services/streamable_service.dart';
9import '../utils/date_time_utils.dart';
10import '../utils/url_launcher.dart';
11import 'bluesky_post_card.dart';
12import 'external_link_bar.dart';
13import 'fullscreen_video_player.dart';
14import 'rich_text_renderer.dart';
15import 'source_link_bar.dart';
16import 'tappable_author.dart';
17
18/// Social media style post detail view inspired by Reddit's clean, content-first design.
19///
20/// Features:
21/// - Compact author row with avatar, handle, and timestamp
22/// - Content-first layout with minimal decoration
23/// - Full-width media that fills available space
24/// - Clean sans-serif typography throughout
25/// - Subtle card backgrounds for embedded content
26class DetailedPostView extends StatefulWidget {
27 const DetailedPostView({
28 required this.post,
29 this.currentTime,
30 this.showSources = true,
31 super.key,
32 });
33
34 final FeedViewPost post;
35 final DateTime? currentTime;
36 final bool showSources;
37
38 @override
39 State<DetailedPostView> createState() => _DetailedPostViewState();
40}
41
42class _DetailedPostViewState extends State<DetailedPostView> {
43 // Image carousel state
44 int _currentImageIndex = 0;
45 final PageController _imagePageController = PageController();
46
47 @override
48 void dispose() {
49 _imagePageController.dispose();
50 super.dispose();
51 }
52
53 /// Determines the content type for layout decisions
54 _ContentType get _contentType {
55 final embed = widget.post.post.embed?.external;
56 if (embed == null) {
57 return _ContentType.textOnly;
58 }
59
60 final embedType = embed.embedType?.toLowerCase();
61 if (embedType == 'video' || embedType == 'video-stream') {
62 return _ContentType.video;
63 }
64
65 if (embed.images != null && embed.images!.length > 1) {
66 return _ContentType.multiImage;
67 }
68
69 if (embed.thumb != null) {
70 return _ContentType.singleImage;
71 }
72
73 return _ContentType.link;
74 }
75
76 @override
77 Widget build(BuildContext context) {
78 return Column(
79 crossAxisAlignment: CrossAxisAlignment.start,
80 children: [
81 // Author row - compact Reddit-style
82 _buildAuthorRow(),
83
84 // Title - prominent but clean
85 if (widget.post.post.title != null) ...[
86 const SizedBox(height: 12),
87 _buildTitle(),
88 ],
89
90 // Media section - full width, content-first
91 if (widget.post.post.embed?.external != null ||
92 widget.post.post.embed?.blueskyPost != null) ...[
93 const SizedBox(height: 12),
94 _buildMediaSection(),
95 ],
96
97 // Post text - clean and readable
98 if (widget.post.post.text.isNotEmpty) ...[
99 const SizedBox(height: 12),
100 _buildBodyText(),
101 ],
102
103 // External link bar
104 if (widget.post.post.embed?.external != null) ...[
105 const SizedBox(height: 12),
106 _buildExternalLink(),
107 ],
108
109 // Bluesky post embed
110 if (widget.post.post.embed?.blueskyPost != null) ...[
111 const SizedBox(height: 12),
112 BlueskyPostCard(
113 embed: widget.post.post.embed!.blueskyPost!,
114 currentTime: widget.currentTime,
115 ),
116 ],
117
118 // Sources section
119 if (widget.showSources) _buildSourcesSection(),
120 ],
121 );
122 }
123
124 /// Reddit-style author row: avatar • @handle • time
125 Widget _buildAuthorRow() {
126 final author = widget.post.post.author;
127
128 return Padding(
129 padding: const EdgeInsets.symmetric(horizontal: 16),
130 child: TappableAuthor(
131 authorDid: author.did,
132 child: Row(
133 children: [
134 // Small circular avatar
135 _buildAvatar(author),
136 const SizedBox(width: 8),
137
138 // Handle with @ prefix - always shown in muted grey
139 Text(
140 '@${author.handle}',
141 style: GoogleFonts.inter(
142 fontSize: 13,
143 fontWeight: FontWeight.w500,
144 color: AppColors.textSecondary,
145 ),
146 ),
147
148 // Dot separator
149 Padding(
150 padding: const EdgeInsets.symmetric(horizontal: 6),
151 child: Text(
152 '•',
153 style: TextStyle(
154 fontSize: 12,
155 color: AppColors.textSecondary.withValues(alpha: 0.6),
156 ),
157 ),
158 ),
159
160 // Time ago
161 Text(
162 DateTimeUtils.formatTimeAgo(
163 widget.post.post.createdAt,
164 currentTime: widget.currentTime,
165 ),
166 style: GoogleFonts.inter(
167 fontSize: 13,
168 color: AppColors.textSecondary,
169 ),
170 ),
171 ],
172 ),
173 ),
174 );
175 }
176
177 /// Small circular avatar
178 Widget _buildAvatar(AuthorView author) {
179 const size = 22.0;
180
181 if (author.avatar != null && author.avatar!.isNotEmpty) {
182 return ClipRRect(
183 borderRadius: BorderRadius.circular(size / 2),
184 child: CachedNetworkImage(
185 imageUrl: author.avatar!,
186 width: size,
187 height: size,
188 fit: BoxFit.cover,
189 fadeInDuration: Duration.zero,
190 fadeOutDuration: Duration.zero,
191 placeholder: (context, url) => _buildAvatarPlaceholder(author, size),
192 errorWidget: (context, url, error) =>
193 _buildAvatarPlaceholder(author, size),
194 ),
195 );
196 }
197
198 return _buildAvatarPlaceholder(author, size);
199 }
200
201 /// Placeholder avatar with initial
202 Widget _buildAvatarPlaceholder(AuthorView author, double size) {
203 final initial = (author.displayName ?? author.handle).isNotEmpty
204 ? (author.displayName ?? author.handle)[0].toUpperCase()
205 : '?';
206
207 return Container(
208 width: size,
209 height: size,
210 decoration: BoxDecoration(
211 color: AppColors.coral.withValues(alpha: 0.2),
212 shape: BoxShape.circle,
213 ),
214 child: Center(
215 child: Text(
216 initial,
217 style: GoogleFonts.inter(
218 fontSize: size * 0.45,
219 fontWeight: FontWeight.w600,
220 color: AppColors.coral,
221 ),
222 ),
223 ),
224 );
225 }
226
227 /// Title - slightly larger than body, not oversized
228 Widget _buildTitle() {
229 return Padding(
230 padding: const EdgeInsets.symmetric(horizontal: 16),
231 child: Text(
232 widget.post.post.title!,
233 style: GoogleFonts.inter(
234 fontSize: 15,
235 fontWeight: FontWeight.w600,
236 color: AppColors.textPrimary,
237 height: 1.35,
238 ),
239 ),
240 );
241 }
242
243 /// Main media section based on content type
244 Widget _buildMediaSection() {
245 switch (_contentType) {
246 case _ContentType.video:
247 return _buildVideoPlayer();
248 case _ContentType.multiImage:
249 return _buildImageCarousel();
250 case _ContentType.singleImage:
251 return _buildSingleImage();
252 case _ContentType.link:
253 case _ContentType.textOnly:
254 return const SizedBox.shrink();
255 }
256 }
257
258 /// Video player with play button overlay
259 Widget _buildVideoPlayer() {
260 final embed = widget.post.post.embed!.external!;
261
262 return _VideoEmbed(
263 embed: embed,
264 streamableService: context.read<StreamableService>(),
265 );
266 }
267
268 /// Image carousel for multi-image posts with attached link bar
269 Widget _buildImageCarousel() {
270 final embed = widget.post.post.embed!.external!;
271 final images = embed.images ?? [];
272
273 if (images.isEmpty) return const SizedBox.shrink();
274
275 return Padding(
276 padding: const EdgeInsets.symmetric(horizontal: 16),
277 child: Container(
278 decoration: BoxDecoration(
279 borderRadius: BorderRadius.circular(8),
280 border: Border.all(
281 color: AppColors.border.withValues(alpha: 0.5),
282 ),
283 ),
284 child: Column(
285 children: [
286 // Images carousel (top of card)
287 ClipRRect(
288 borderRadius: const BorderRadius.only(
289 topLeft: Radius.circular(7),
290 topRight: Radius.circular(7),
291 ),
292 child: GestureDetector(
293 onTap: () => UrlLauncher.launchExternalUrl(
294 embed.uri,
295 context: context,
296 ),
297 child: SizedBox(
298 height: 300,
299 child: PageView.builder(
300 controller: _imagePageController,
301 onPageChanged: (index) {
302 setState(() => _currentImageIndex = index);
303 },
304 itemCount: images.length,
305 itemBuilder: (context, index) {
306 final image = images[index];
307 final imageUrl = image['thumb'] as String? ??
308 image['fullsize'] as String? ??
309 '';
310
311 if (imageUrl.isEmpty) return _buildImagePlaceholder();
312
313 return CachedNetworkImage(
314 imageUrl: imageUrl,
315 fit: BoxFit.cover,
316 fadeInDuration: Duration.zero,
317 fadeOutDuration: Duration.zero,
318 placeholder: (context, url) => _buildImagePlaceholder(),
319 errorWidget: (context, url, error) =>
320 _buildImagePlaceholder(),
321 );
322 },
323 ),
324 ),
325 ),
326 ),
327
328 // Link bar with page indicator (bottom of card)
329 GestureDetector(
330 onTap: () => UrlLauncher.launchExternalUrl(
331 embed.uri,
332 context: context,
333 ),
334 child: Container(
335 padding: const EdgeInsets.all(10),
336 decoration: BoxDecoration(
337 color: AppColors.backgroundSecondary,
338 borderRadius: const BorderRadius.only(
339 bottomLeft: Radius.circular(7),
340 bottomRight: Radius.circular(7),
341 ),
342 ),
343 child: Row(
344 children: [
345 _buildFavicon(embed.uri),
346 const SizedBox(width: 8),
347 Expanded(
348 child: Text(
349 _formatUrlForDisplay(embed.uri),
350 style: GoogleFonts.inter(
351 fontSize: 13,
352 color: AppColors.textPrimary.withValues(alpha: 0.7),
353 ),
354 maxLines: 1,
355 overflow: TextOverflow.ellipsis,
356 ),
357 ),
358
359 // Page counter (if multiple images)
360 if (images.length > 1) ...[
361 const SizedBox(width: 8),
362 Container(
363 padding: const EdgeInsets.symmetric(
364 horizontal: 8,
365 vertical: 2,
366 ),
367 decoration: BoxDecoration(
368 color: AppColors.background.withValues(alpha: 0.5),
369 borderRadius: BorderRadius.circular(10),
370 ),
371 child: Text(
372 '${_currentImageIndex + 1}/${images.length}',
373 style: GoogleFonts.inter(
374 fontSize: 11,
375 fontWeight: FontWeight.w500,
376 color: AppColors.textSecondary,
377 ),
378 ),
379 ),
380 ],
381
382 const SizedBox(width: 8),
383 Icon(
384 Icons.open_in_new,
385 size: 14,
386 color: AppColors.textPrimary.withValues(alpha: 0.7),
387 ),
388 ],
389 ),
390 ),
391 ),
392 ],
393 ),
394 ),
395 );
396 }
397
398 /// Single full-width image with attached link bar
399 Widget _buildSingleImage() {
400 final embed = widget.post.post.embed!.external!;
401
402 if (embed.thumb == null) return const SizedBox.shrink();
403
404 return Padding(
405 padding: const EdgeInsets.symmetric(horizontal: 16),
406 child: GestureDetector(
407 onTap: () => UrlLauncher.launchExternalUrl(
408 embed.uri,
409 context: context,
410 ),
411 child: Container(
412 decoration: BoxDecoration(
413 borderRadius: BorderRadius.circular(8),
414 border: Border.all(
415 color: AppColors.border.withValues(alpha: 0.5),
416 ),
417 ),
418 child: Column(
419 crossAxisAlignment: CrossAxisAlignment.start,
420 children: [
421 // Image (top of card)
422 ClipRRect(
423 borderRadius: const BorderRadius.only(
424 topLeft: Radius.circular(7),
425 topRight: Radius.circular(7),
426 ),
427 child: CachedNetworkImage(
428 imageUrl: embed.thumb!,
429 width: double.infinity,
430 height: 220,
431 fit: BoxFit.cover,
432 fadeInDuration: Duration.zero,
433 fadeOutDuration: Duration.zero,
434 placeholder: (context, url) => Container(
435 height: 220,
436 color: AppColors.backgroundSecondary,
437 ),
438 errorWidget: (context, url, error) => Container(
439 height: 220,
440 color: AppColors.backgroundSecondary,
441 child: const Center(
442 child: Icon(
443 Icons.image_outlined,
444 color: AppColors.textMuted,
445 size: 40,
446 ),
447 ),
448 ),
449 ),
450 ),
451
452 // Link bar (bottom of card)
453 Container(
454 padding: const EdgeInsets.all(10),
455 decoration: BoxDecoration(
456 color: AppColors.backgroundSecondary,
457 borderRadius: const BorderRadius.only(
458 bottomLeft: Radius.circular(7),
459 bottomRight: Radius.circular(7),
460 ),
461 ),
462 child: Row(
463 children: [
464 _buildFavicon(embed.uri),
465 const SizedBox(width: 8),
466 Expanded(
467 child: Text(
468 _formatUrlForDisplay(embed.uri),
469 style: GoogleFonts.inter(
470 fontSize: 13,
471 color: AppColors.textPrimary.withValues(alpha: 0.7),
472 ),
473 maxLines: 1,
474 overflow: TextOverflow.ellipsis,
475 ),
476 ),
477 const SizedBox(width: 8),
478 Icon(
479 Icons.open_in_new,
480 size: 14,
481 color: AppColors.textPrimary.withValues(alpha: 0.7),
482 ),
483 ],
484 ),
485 ),
486 ],
487 ),
488 ),
489 ),
490 );
491 }
492
493 /// Clean body text
494 Widget _buildBodyText() {
495 return Padding(
496 padding: const EdgeInsets.symmetric(horizontal: 16),
497 child: RichTextRenderer(
498 text: widget.post.post.text,
499 facets: widget.post.post.facets,
500 style: GoogleFonts.inter(
501 fontSize: 12.5,
502 fontWeight: FontWeight.w400,
503 color: AppColors.textPrimary.withValues(alpha: 0.95),
504 height: 1.5,
505 ),
506 ),
507 );
508 }
509
510 /// External link bar in a subtle card
511 Widget _buildExternalLink() {
512 return Padding(
513 padding: const EdgeInsets.symmetric(horizontal: 16),
514 child: Container(
515 decoration: BoxDecoration(
516 color: AppColors.backgroundSecondary.withValues(alpha: 0.5),
517 borderRadius: BorderRadius.circular(8),
518 border: Border.all(
519 color: AppColors.border.withValues(alpha: 0.5),
520 ),
521 ),
522 child: ClipRRect(
523 borderRadius: BorderRadius.circular(8),
524 child: ExternalLinkBar(embed: widget.post.post.embed!.external!),
525 ),
526 ),
527 );
528 }
529
530 /// Sources section for megathreads
531 Widget _buildSourcesSection() {
532 final sources = widget.post.post.embed?.external?.sources;
533 if (sources == null || sources.isEmpty) return const SizedBox.shrink();
534
535 return Padding(
536 padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
537 child: Column(
538 crossAxisAlignment: CrossAxisAlignment.start,
539 children: [
540 // Header
541 Text(
542 'Sources',
543 style: GoogleFonts.inter(
544 fontSize: 12,
545 fontWeight: FontWeight.w600,
546 color: AppColors.textSecondary,
547 letterSpacing: 0.5,
548 ),
549 ),
550 const SizedBox(height: 8),
551
552 // Source links
553 ...sources.map(
554 (source) => Padding(
555 padding: const EdgeInsets.only(bottom: 6),
556 child: SourceLinkBar(source: source),
557 ),
558 ),
559 ],
560 ),
561 );
562 }
563
564 /// Image placeholder
565 Widget _buildImagePlaceholder() {
566 return Container(
567 height: 280,
568 color: AppColors.backgroundSecondary,
569 child: const Center(
570 child: Icon(
571 Icons.image_outlined,
572 color: AppColors.textMuted,
573 size: 40,
574 ),
575 ),
576 );
577 }
578
579 /// Formats a URL for display (removes protocol, keeps domain + path start)
580 String _formatUrlForDisplay(String url) {
581 try {
582 final uri = Uri.parse(url);
583 final host = uri.host;
584 final path = uri.path;
585
586 // Combine host and path, removing trailing slash if present
587 if (path.isEmpty || path == '/') {
588 return host;
589 }
590 return '$host$path';
591 } on FormatException {
592 return url;
593 }
594 }
595
596 /// Builds a favicon widget for the given URL
597 Widget _buildFavicon(String url) {
598 String? domain;
599 try {
600 final uri = Uri.parse(url);
601 domain = uri.host;
602 } on FormatException {
603 domain = null;
604 }
605
606 if (domain == null || domain.isEmpty) {
607 return Icon(
608 Icons.link,
609 size: 18,
610 color: AppColors.textPrimary.withValues(alpha: 0.7),
611 );
612 }
613
614 final faviconUrl =
615 'https://www.google.com/s2/favicons?domain=$domain&sz=32';
616
617 return ClipRRect(
618 borderRadius: BorderRadius.circular(4),
619 child: CachedNetworkImage(
620 imageUrl: faviconUrl,
621 width: 18,
622 height: 18,
623 fit: BoxFit.cover,
624 fadeInDuration: Duration.zero,
625 fadeOutDuration: Duration.zero,
626 placeholder: (context, url) => Icon(
627 Icons.link,
628 size: 18,
629 color: AppColors.textPrimary.withValues(alpha: 0.7),
630 ),
631 errorWidget: (context, url, error) => Icon(
632 Icons.link,
633 size: 18,
634 color: AppColors.textPrimary.withValues(alpha: 0.7),
635 ),
636 ),
637 );
638 }
639}
640
641/// Video embed with play button overlay
642class _VideoEmbed extends StatefulWidget {
643 const _VideoEmbed({required this.embed, required this.streamableService});
644
645 final ExternalEmbed embed;
646 final StreamableService streamableService;
647
648 @override
649 State<_VideoEmbed> createState() => _VideoEmbedState();
650}
651
652class _VideoEmbedState extends State<_VideoEmbed> {
653 bool _isLoading = false;
654
655 bool get _isStreamable =>
656 widget.embed.provider?.toLowerCase() == 'streamable';
657
658 Future<void> _playVideo() async {
659 if (!_isStreamable || widget.embed.thumb == null) return;
660
661 final messenger = ScaffoldMessenger.of(context);
662 final navigator = Navigator.of(context);
663
664 setState(() => _isLoading = true);
665
666 try {
667 final videoUrl =
668 await widget.streamableService.getVideoUrl(widget.embed.uri);
669
670 if (!mounted) return;
671
672 if (videoUrl == null) {
673 messenger.showSnackBar(
674 SnackBar(
675 content: Text(
676 'Could not load video',
677 style: GoogleFonts.inter(color: AppColors.textPrimary),
678 ),
679 backgroundColor: AppColors.backgroundSecondary,
680 ),
681 );
682 return;
683 }
684
685 await navigator.push<void>(
686 MaterialPageRoute(
687 builder: (context) => FullscreenVideoPlayer(videoUrl: videoUrl),
688 fullscreenDialog: true,
689 ),
690 );
691 } finally {
692 if (mounted) {
693 setState(() => _isLoading = false);
694 }
695 }
696 }
697
698 @override
699 Widget build(BuildContext context) {
700 if (widget.embed.thumb == null) return const SizedBox.shrink();
701
702 return GestureDetector(
703 onTap: _isLoading ? null : _playVideo,
704 child: Stack(
705 alignment: Alignment.center,
706 children: [
707 // Video thumbnail - full width
708 CachedNetworkImage(
709 imageUrl: widget.embed.thumb!,
710 width: double.infinity,
711 height: 240,
712 fit: BoxFit.cover,
713 fadeInDuration: Duration.zero,
714 fadeOutDuration: Duration.zero,
715 placeholder: (context, url) => Container(
716 height: 240,
717 color: AppColors.backgroundSecondary,
718 ),
719 errorWidget: (context, url, error) => Container(
720 height: 240,
721 color: AppColors.backgroundSecondary,
722 child: const Center(
723 child: Icon(
724 Icons.broken_image,
725 color: AppColors.textMuted,
726 size: 40,
727 ),
728 ),
729 ),
730 ),
731
732 // Darkening overlay
733 Positioned.fill(
734 child: Container(
735 color: Colors.black.withValues(alpha: 0.3),
736 ),
737 ),
738
739 // Play button - simple and clean
740 Container(
741 width: 64,
742 height: 64,
743 decoration: BoxDecoration(
744 color: AppColors.textPrimary.withValues(alpha: 0.9),
745 shape: BoxShape.circle,
746 ),
747 child: _isLoading
748 ? const Padding(
749 padding: EdgeInsets.all(18),
750 child: CircularProgressIndicator(
751 color: AppColors.background,
752 strokeWidth: 2.5,
753 ),
754 )
755 : const Icon(
756 Icons.play_arrow_rounded,
757 color: AppColors.background,
758 size: 36,
759 ),
760 ),
761 ],
762 ),
763 );
764 }
765}
766
767/// Content type enum for layout decisions
768enum _ContentType {
769 video,
770 singleImage,
771 multiImage,
772 link,
773 textOnly,
774}