Main coves client
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 774 lines 23 kB view raw
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}