Main coves client
fork

Configure Feed

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

feat(community): add community feed screen with collapsing header and tabs

Implement full community feed navigation allowing users to tap on community
avatars/names in post cards to view community details and browse posts.

Changes:
- Add CommunityFeedScreen with collapsing SliverAppBar and frosted glass effect
- Add Feed/About tabs with feed sorting (Hot, New, Top)
- Add getCommunity() and getCommunityFeed() API methods
- Add TappableCommunity widget for consistent navigation from post cards
- Add CommunityHeader widget matching profile header design pattern
- Add DisplayUtils for shared avatar colors and number formatting
- Add share functionality for communities
- Improve error handling with specific exception types (Network, NotFound, Server)
- Add subscribe/join button with loading states and animations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+1900 -9
+13
lib/main.dart
··· 9 9 10 10 import 'config/oauth_config.dart'; 11 11 import 'constants/app_colors.dart'; 12 + import 'models/community.dart'; 12 13 import 'models/post.dart'; 13 14 import 'providers/auth_provider.dart'; 14 15 import 'providers/community_subscription_provider.dart'; ··· 16 17 import 'providers/user_profile_provider.dart'; 17 18 import 'providers/vote_provider.dart'; 18 19 import 'screens/auth/login_screen.dart'; 20 + import 'screens/community/community_feed_screen.dart'; 19 21 import 'screens/home/main_shell_screen.dart'; 20 22 import 'screens/home/post_detail_screen.dart'; 21 23 import 'screens/home/profile_screen.dart'; ··· 205 207 builder: (context, state) { 206 208 final actor = state.pathParameters['actor']!; 207 209 return ProfileScreen(actor: actor); 210 + }, 211 + ), 212 + GoRoute( 213 + path: '/community/:identifier', 214 + builder: (context, state) { 215 + final identifier = state.pathParameters['identifier']!; 216 + final community = state.extra as CommunityView?; 217 + return CommunityFeedScreen( 218 + identifier: identifier, 219 + community: community, 220 + ); 208 221 }, 209 222 ), 210 223 GoRoute(
+1355
lib/screens/community/community_feed_screen.dart
··· 1 + import 'dart:ui'; 2 + 3 + import 'package:cached_network_image/cached_network_image.dart'; 4 + import 'package:flutter/foundation.dart'; 5 + import 'package:flutter/material.dart'; 6 + import 'package:go_router/go_router.dart'; 7 + import 'package:provider/provider.dart'; 8 + import 'package:share_plus/share_plus.dart'; 9 + 10 + import '../../constants/app_colors.dart'; 11 + import '../../models/community.dart'; 12 + import '../../models/post.dart'; 13 + import '../../providers/auth_provider.dart'; 14 + import '../../providers/community_subscription_provider.dart'; 15 + import '../../providers/vote_provider.dart'; 16 + import '../../services/api_exceptions.dart'; 17 + import '../../services/coves_api_service.dart'; 18 + import '../../utils/display_utils.dart'; 19 + import '../../utils/error_messages.dart'; 20 + import '../../widgets/community_header.dart'; 21 + import '../../widgets/loading_error_states.dart'; 22 + import '../../widgets/post_card.dart'; 23 + 24 + /// Screen displaying a community's feed with header info 25 + /// 26 + /// Features a collapsing header similar to profile screen with: 27 + /// - Banner image with gradient overlay 28 + /// - Community avatar, name, and description 29 + /// - Tabbed content (Feed, About) 30 + /// - Subscribe button in app bar 31 + class CommunityFeedScreen extends StatefulWidget { 32 + const CommunityFeedScreen({ 33 + required this.identifier, 34 + this.community, 35 + super.key, 36 + }); 37 + 38 + /// Community DID or handle 39 + final String identifier; 40 + 41 + /// Pre-fetched community data (optional, for faster initial display) 42 + final CommunityView? community; 43 + 44 + @override 45 + State<CommunityFeedScreen> createState() => _CommunityFeedScreenState(); 46 + } 47 + 48 + class _CommunityFeedScreenState extends State<CommunityFeedScreen> { 49 + CovesApiService? _apiService; 50 + final ScrollController _scrollController = ScrollController(); 51 + 52 + // Tab state 53 + int _selectedTabIndex = 0; 54 + 55 + // Feed sort state 56 + String _feedSort = 'hot'; 57 + 58 + // Community state 59 + CommunityView? _community; 60 + bool _isLoadingCommunity = false; 61 + String? _communityError; 62 + 63 + // Feed state 64 + List<FeedViewPost> _posts = []; 65 + bool _isLoadingFeed = false; 66 + bool _isLoadingMore = false; 67 + String? _feedError; 68 + String? _loadMoreError; 69 + String? _cursor; 70 + bool _hasMore = true; 71 + 72 + // Time for relative timestamps 73 + DateTime _currentTime = DateTime.now(); 74 + 75 + @override 76 + void initState() { 77 + super.initState(); 78 + _community = widget.community; 79 + _scrollController.addListener(_onScroll); 80 + 81 + WidgetsBinding.instance.addPostFrameCallback((_) { 82 + if (mounted) { 83 + _initializeAndLoad(); 84 + } 85 + }); 86 + } 87 + 88 + @override 89 + void dispose() { 90 + _scrollController.dispose(); 91 + _apiService?.dispose(); 92 + super.dispose(); 93 + } 94 + 95 + CovesApiService _getApiService() { 96 + if (_apiService == null) { 97 + final authProvider = context.read<AuthProvider>(); 98 + _apiService = CovesApiService( 99 + tokenGetter: authProvider.getAccessToken, 100 + tokenRefresher: authProvider.refreshToken, 101 + signOutHandler: authProvider.signOut, 102 + ); 103 + } 104 + return _apiService!; 105 + } 106 + 107 + void _onScroll() { 108 + if (_scrollController.position.pixels >= 109 + _scrollController.position.maxScrollExtent - 200) { 110 + _loadMore(); 111 + } 112 + } 113 + 114 + void _onTabChanged(int index) { 115 + setState(() { 116 + _selectedTabIndex = index; 117 + }); 118 + } 119 + 120 + void _onFeedSortChanged(String sort) { 121 + if (_feedSort == sort) return; 122 + setState(() { 123 + _feedSort = sort; 124 + }); 125 + _loadFeed(refresh: true); 126 + } 127 + 128 + Future<void> _initializeAndLoad() async { 129 + if (_community == null) { 130 + await _loadCommunity(); 131 + } 132 + await _loadFeed(refresh: true); 133 + } 134 + 135 + Future<void> _loadCommunity() async { 136 + if (_isLoadingCommunity) return; 137 + 138 + setState(() { 139 + _isLoadingCommunity = true; 140 + _communityError = null; 141 + }); 142 + 143 + try { 144 + final apiService = _getApiService(); 145 + final community = await apiService.getCommunity( 146 + community: widget.identifier, 147 + ); 148 + 149 + if (mounted) { 150 + setState(() { 151 + _community = community; 152 + _isLoadingCommunity = false; 153 + }); 154 + 155 + // Initialize subscription state from community viewer data 156 + final authProvider = context.read<AuthProvider>(); 157 + if (authProvider.isAuthenticated && 158 + community.viewer?.subscribed != null) { 159 + final subscriptionProvider = 160 + context.read<CommunitySubscriptionProvider>(); 161 + subscriptionProvider.setInitialSubscriptionState( 162 + communityDid: community.did, 163 + isSubscribed: community.viewer!.subscribed!, 164 + ); 165 + } 166 + } 167 + } on NetworkException catch (e) { 168 + if (kDebugMode) { 169 + debugPrint('Network error loading community: $e'); 170 + } 171 + if (mounted) { 172 + setState(() { 173 + _communityError = 'Please check your internet connection'; 174 + _isLoadingCommunity = false; 175 + }); 176 + } 177 + } on NotFoundException catch (e) { 178 + if (kDebugMode) { 179 + debugPrint('Community not found: $e'); 180 + } 181 + if (mounted) { 182 + setState(() { 183 + _communityError = 'Community not found'; 184 + _isLoadingCommunity = false; 185 + }); 186 + } 187 + } on ServerException catch (e) { 188 + if (kDebugMode) { 189 + debugPrint('Server error loading community: $e'); 190 + } 191 + if (mounted) { 192 + setState(() { 193 + _communityError = 'Server error. Please try again later'; 194 + _isLoadingCommunity = false; 195 + }); 196 + } 197 + } on ApiException catch (e) { 198 + if (kDebugMode) { 199 + debugPrint('API error loading community: $e'); 200 + } 201 + if (mounted) { 202 + setState(() { 203 + _communityError = e.message; 204 + _isLoadingCommunity = false; 205 + }); 206 + } 207 + } on Exception catch (e) { 208 + if (kDebugMode) { 209 + debugPrint('Error loading community: $e'); 210 + } 211 + if (mounted) { 212 + setState(() { 213 + _communityError = ErrorMessages.getUserFriendly(e.toString()); 214 + _isLoadingCommunity = false; 215 + }); 216 + } 217 + } 218 + } 219 + 220 + Future<void> _loadFeed({bool refresh = false}) async { 221 + if (_isLoadingFeed) return; 222 + 223 + setState(() { 224 + _isLoadingFeed = true; 225 + if (refresh) { 226 + _feedError = null; 227 + _cursor = null; 228 + _hasMore = true; 229 + } 230 + }); 231 + 232 + try { 233 + final apiService = _getApiService(); 234 + final response = await apiService.getCommunityFeed( 235 + community: widget.identifier, 236 + sort: _feedSort, 237 + cursor: refresh ? null : _cursor, 238 + ); 239 + 240 + if (mounted) { 241 + setState(() { 242 + _currentTime = DateTime.now(); 243 + if (refresh) { 244 + _posts = response.feed; 245 + } else { 246 + _posts = [..._posts, ...response.feed]; 247 + } 248 + _cursor = response.cursor; 249 + _hasMore = response.cursor != null; 250 + _isLoadingFeed = false; 251 + }); 252 + 253 + _syncViewerStates(response.feed); 254 + } 255 + } on NetworkException catch (e) { 256 + if (kDebugMode) { 257 + debugPrint('Network error loading feed: $e'); 258 + } 259 + if (mounted) { 260 + setState(() { 261 + _feedError = 'Please check your internet connection'; 262 + _isLoadingFeed = false; 263 + }); 264 + } 265 + } on ServerException catch (e) { 266 + if (kDebugMode) { 267 + debugPrint('Server error loading feed: $e'); 268 + } 269 + if (mounted) { 270 + setState(() { 271 + _feedError = 'Server error. Please try again later'; 272 + _isLoadingFeed = false; 273 + }); 274 + } 275 + } on ApiException catch (e) { 276 + if (kDebugMode) { 277 + debugPrint('API error loading feed: $e'); 278 + } 279 + if (mounted) { 280 + setState(() { 281 + _feedError = e.message; 282 + _isLoadingFeed = false; 283 + }); 284 + } 285 + } on Exception catch (e) { 286 + if (kDebugMode) { 287 + debugPrint('Error loading community feed: $e'); 288 + } 289 + if (mounted) { 290 + setState(() { 291 + _feedError = ErrorMessages.getUserFriendly(e.toString()); 292 + _isLoadingFeed = false; 293 + }); 294 + } 295 + } 296 + } 297 + 298 + Future<void> _loadMore() async { 299 + if (_isLoadingMore || !_hasMore || _isLoadingFeed) return; 300 + 301 + setState(() { 302 + _isLoadingMore = true; 303 + _loadMoreError = null; 304 + }); 305 + 306 + try { 307 + final apiService = _getApiService(); 308 + final response = await apiService.getCommunityFeed( 309 + community: widget.identifier, 310 + sort: _feedSort, 311 + cursor: _cursor, 312 + ); 313 + 314 + if (mounted) { 315 + setState(() { 316 + _posts = [..._posts, ...response.feed]; 317 + _cursor = response.cursor; 318 + _hasMore = response.cursor != null; 319 + _isLoadingMore = false; 320 + }); 321 + 322 + _syncViewerStates(response.feed); 323 + } 324 + } on NetworkException catch (e) { 325 + if (kDebugMode) { 326 + debugPrint('Network error loading more posts: $e'); 327 + } 328 + if (mounted) { 329 + setState(() { 330 + _isLoadingMore = false; 331 + _loadMoreError = 'Please check your internet connection'; 332 + }); 333 + } 334 + } on ApiException catch (e) { 335 + if (kDebugMode) { 336 + debugPrint('API error loading more posts: $e'); 337 + } 338 + if (mounted) { 339 + setState(() { 340 + _isLoadingMore = false; 341 + _loadMoreError = e.message; 342 + }); 343 + } 344 + } on Exception catch (e) { 345 + if (kDebugMode) { 346 + debugPrint('Error loading more posts: $e'); 347 + } 348 + if (mounted) { 349 + setState(() { 350 + _isLoadingMore = false; 351 + _loadMoreError = ErrorMessages.getUserFriendly(e.toString()); 352 + }); 353 + } 354 + } 355 + } 356 + 357 + void _clearLoadMoreError() { 358 + setState(() { 359 + _loadMoreError = null; 360 + }); 361 + } 362 + 363 + void _syncViewerStates(List<FeedViewPost> posts) { 364 + final authProvider = context.read<AuthProvider>(); 365 + if (!authProvider.isAuthenticated) return; 366 + 367 + final voteProvider = context.read<VoteProvider>(); 368 + final subscriptionProvider = context.read<CommunitySubscriptionProvider>(); 369 + 370 + for (final post in posts) { 371 + final viewer = post.post.viewer; 372 + voteProvider.setInitialVoteState( 373 + postUri: post.post.uri, 374 + voteDirection: viewer?.vote, 375 + voteUri: viewer?.voteUri, 376 + ); 377 + 378 + final communityViewer = post.post.community.viewer; 379 + if (communityViewer?.subscribed != null) { 380 + subscriptionProvider.setInitialSubscriptionState( 381 + communityDid: post.post.community.did, 382 + isSubscribed: communityViewer!.subscribed!, 383 + ); 384 + } 385 + } 386 + } 387 + 388 + Future<void> _onRefresh() async { 389 + await _loadCommunity(); 390 + await _loadFeed(refresh: true); 391 + } 392 + 393 + Future<void> _handleShare() async { 394 + if (_community == null) return; 395 + 396 + final handle = _community!.handle; 397 + final communityUrl = 'https://coves.social/community/$handle'; 398 + final subject = 399 + 'Check out ${_community!.displayName ?? _community!.name} on Coves'; 400 + 401 + try { 402 + await Share.share(communityUrl, subject: subject); 403 + } on Exception catch (e) { 404 + if (kDebugMode) { 405 + debugPrint('Error sharing community: $e'); 406 + } 407 + if (mounted) { 408 + ScaffoldMessenger.of(context).showSnackBar( 409 + const SnackBar( 410 + content: Text('Failed to share. Please try again.'), 411 + behavior: SnackBarBehavior.floating, 412 + backgroundColor: AppColors.primary, 413 + ), 414 + ); 415 + } 416 + } 417 + } 418 + 419 + @override 420 + Widget build(BuildContext context) { 421 + // Loading community info 422 + if (_isLoadingCommunity && _community == null) { 423 + return Scaffold( 424 + backgroundColor: AppColors.background, 425 + appBar: _buildSimpleAppBar(), 426 + body: const FullScreenLoading(), 427 + ); 428 + } 429 + 430 + // Error loading community 431 + if (_communityError != null && _community == null) { 432 + return Scaffold( 433 + backgroundColor: AppColors.background, 434 + appBar: _buildSimpleAppBar(), 435 + body: FullScreenError( 436 + title: 'Community not found', 437 + message: _communityError!, 438 + onRetry: _loadCommunity, 439 + ), 440 + ); 441 + } 442 + 443 + return Scaffold( 444 + backgroundColor: AppColors.background, 445 + body: RefreshIndicator( 446 + color: AppColors.primary, 447 + backgroundColor: AppColors.backgroundSecondary, 448 + onRefresh: _onRefresh, 449 + child: CustomScrollView( 450 + controller: _scrollController, 451 + physics: const AlwaysScrollableScrollPhysics(), 452 + slivers: [ 453 + // Collapsing app bar with community header 454 + SliverAppBar( 455 + backgroundColor: Colors.transparent, 456 + foregroundColor: AppColors.textPrimary, 457 + expandedHeight: 220, 458 + pinned: true, 459 + stretch: true, 460 + leading: IconButton( 461 + icon: const Icon(Icons.arrow_back), 462 + onPressed: () => context.pop(), 463 + ), 464 + actions: [ 465 + _buildSubscribeButton(), 466 + IconButton( 467 + icon: const Icon(Icons.share_outlined), 468 + onPressed: _handleShare, 469 + tooltip: 'Share Community', 470 + ), 471 + ], 472 + flexibleSpace: LayoutBuilder( 473 + builder: (context, constraints) { 474 + const expandedHeight = 220.0; 475 + final collapsedHeight = 476 + kToolbarHeight + MediaQuery.of(context).padding.top; 477 + final currentHeight = constraints.maxHeight; 478 + final collapseProgress = 1 - 479 + ((currentHeight - collapsedHeight) / 480 + (expandedHeight - collapsedHeight)) 481 + .clamp(0.0, 1.0); 482 + 483 + return Stack( 484 + fit: StackFit.expand, 485 + children: [ 486 + // Community header background 487 + Positioned( 488 + top: 0, 489 + left: 0, 490 + right: 0, 491 + bottom: 0, 492 + child: CommunityHeader(community: _community), 493 + ), 494 + // Frosted glass overlay when collapsed 495 + if (collapseProgress > 0) 496 + Positioned( 497 + top: 0, 498 + left: 0, 499 + right: 0, 500 + height: collapsedHeight, 501 + child: ClipRect( 502 + child: BackdropFilter( 503 + filter: ImageFilter.blur( 504 + sigmaX: 10 * collapseProgress, 505 + sigmaY: 10 * collapseProgress, 506 + ), 507 + child: Container( 508 + color: AppColors.background 509 + .withValues(alpha: 0.7 * collapseProgress), 510 + ), 511 + ), 512 + ), 513 + ), 514 + // Community name in collapsed app bar 515 + if (collapseProgress > 0.5) 516 + Positioned( 517 + top: 0, 518 + left: 0, 519 + right: 0, 520 + height: collapsedHeight, 521 + child: SafeArea( 522 + bottom: false, 523 + child: Opacity( 524 + opacity: 525 + ((collapseProgress - 0.5) * 2).clamp(0.0, 1.0), 526 + child: Padding( 527 + // Left padding: back button (48) + small gap (8) 528 + // Right padding: action buttons space 529 + padding: 530 + const EdgeInsets.only(left: 56, right: 100), 531 + child: Align( 532 + alignment: Alignment.centerLeft, 533 + child: Row( 534 + mainAxisSize: MainAxisSize.min, 535 + children: [ 536 + if (_community?.avatar != null && 537 + _community!.avatar!.isNotEmpty) 538 + ClipOval( 539 + child: CachedNetworkImage( 540 + imageUrl: _community!.avatar!, 541 + width: 28, 542 + height: 28, 543 + fit: BoxFit.cover, 544 + fadeInDuration: Duration.zero, 545 + fadeOutDuration: Duration.zero, 546 + errorWidget: (context, url, error) { 547 + if (kDebugMode) { 548 + debugPrint( 549 + 'Error loading collapsed avatar: $error', 550 + ); 551 + } 552 + return _buildCollapsedFallbackAvatar(); 553 + }, 554 + ), 555 + ) 556 + else 557 + _buildCollapsedFallbackAvatar(), 558 + const SizedBox(width: 10), 559 + Flexible( 560 + child: Column( 561 + crossAxisAlignment: 562 + CrossAxisAlignment.start, 563 + mainAxisSize: MainAxisSize.min, 564 + children: [ 565 + Text( 566 + '!${_community?.name ?? ''}', 567 + style: const TextStyle( 568 + fontSize: 17, 569 + fontWeight: FontWeight.w700, 570 + color: AppColors.communityName, 571 + ), 572 + overflow: TextOverflow.ellipsis, 573 + ), 574 + Text( 575 + _getInstanceFromHandle(), 576 + style: TextStyle( 577 + fontSize: 12, 578 + fontWeight: FontWeight.w400, 579 + color: AppColors.textSecondary 580 + .withValues(alpha: 0.8), 581 + ), 582 + ), 583 + ], 584 + ), 585 + ), 586 + ], 587 + ), 588 + ), 589 + ), 590 + ), 591 + ), 592 + ), 593 + ], 594 + ); 595 + }, 596 + ), 597 + ), 598 + // Tab bar header (scrolls away) 599 + SliverPersistentHeader( 600 + pinned: false, 601 + delegate: _CommunityTabBarDelegate( 602 + child: Container( 603 + color: AppColors.background, 604 + child: _CommunityTabBar( 605 + selectedIndex: _selectedTabIndex, 606 + onTabChanged: _onTabChanged, 607 + ), 608 + ), 609 + ), 610 + ), 611 + // Feed sort selector - pinned (only shown on Feed tab) 612 + if (_selectedTabIndex == 0) 613 + SliverPersistentHeader( 614 + pinned: true, 615 + delegate: _FeedSortDelegate( 616 + child: Container( 617 + padding: 618 + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 619 + decoration: const BoxDecoration( 620 + color: AppColors.background, 621 + border: Border(bottom: BorderSide(color: AppColors.border)), 622 + ), 623 + child: Row( 624 + children: [ 625 + _FeedSortChip( 626 + label: 'Hot', 627 + icon: Icons.local_fire_department, 628 + isSelected: _feedSort == 'hot', 629 + onTap: () => _onFeedSortChanged('hot'), 630 + ), 631 + const SizedBox(width: 8), 632 + _FeedSortChip( 633 + label: 'New', 634 + icon: Icons.schedule, 635 + isSelected: _feedSort == 'new', 636 + onTap: () => _onFeedSortChanged('new'), 637 + ), 638 + const SizedBox(width: 8), 639 + _FeedSortChip( 640 + label: 'Top', 641 + icon: Icons.trending_up, 642 + isSelected: _feedSort == 'top', 643 + onTap: () => _onFeedSortChanged('top'), 644 + ), 645 + ], 646 + ), 647 + ), 648 + ), 649 + ), 650 + // Content based on selected tab 651 + if (_selectedTabIndex == 0) 652 + _buildPostsList() 653 + else 654 + _buildAboutSection(), 655 + ], 656 + ), 657 + ), 658 + ); 659 + } 660 + 661 + AppBar _buildSimpleAppBar() { 662 + return AppBar( 663 + backgroundColor: AppColors.background, 664 + foregroundColor: AppColors.textPrimary, 665 + title: const Text('Community'), 666 + leading: IconButton( 667 + icon: const Icon(Icons.arrow_back), 668 + onPressed: () => context.pop(), 669 + ), 670 + ); 671 + } 672 + 673 + String _getInstanceFromHandle() { 674 + final handle = _community?.handle; 675 + if (handle == null || !handle.contains('.')) { 676 + return 'coves.social'; 677 + } 678 + final parts = handle.split('.'); 679 + if (parts.length >= 2) { 680 + return parts.sublist(parts.length - 2).join('.'); 681 + } 682 + return 'coves.social'; 683 + } 684 + 685 + Widget _buildCollapsedFallbackAvatar() { 686 + final name = _community?.name ?? ''; 687 + final bgColor = DisplayUtils.getFallbackColor(name); 688 + 689 + return Container( 690 + width: 28, 691 + height: 28, 692 + decoration: BoxDecoration( 693 + color: bgColor, 694 + shape: BoxShape.circle, 695 + ), 696 + child: Center( 697 + child: Text( 698 + name.isNotEmpty ? name[0].toUpperCase() : 'C', 699 + style: const TextStyle( 700 + fontSize: 14, 701 + fontWeight: FontWeight.bold, 702 + color: Colors.white, 703 + ), 704 + ), 705 + ), 706 + ); 707 + } 708 + 709 + Widget _buildSubscribeButton() { 710 + final isAuthenticated = context.watch<AuthProvider>().isAuthenticated; 711 + if (!isAuthenticated || _community == null) { 712 + return const SizedBox.shrink(); 713 + } 714 + 715 + return Consumer<CommunitySubscriptionProvider>( 716 + builder: (context, provider, _) { 717 + final isSubscribed = provider.isSubscribed(_community!.did); 718 + final isPending = provider.isPending(_community!.did); 719 + 720 + return AnimatedSwitcher( 721 + duration: const Duration(milliseconds: 200), 722 + child: isPending 723 + ? Container( 724 + key: const ValueKey('loading'), 725 + width: 32, 726 + height: 32, 727 + alignment: Alignment.center, 728 + child: const SizedBox( 729 + width: 14, 730 + height: 14, 731 + child: CircularProgressIndicator( 732 + strokeWidth: 1.5, 733 + color: AppColors.textPrimary, 734 + ), 735 + ), 736 + ) 737 + : Material( 738 + key: ValueKey('button_$isSubscribed'), 739 + color: Colors.transparent, 740 + borderRadius: BorderRadius.circular(14), 741 + child: InkWell( 742 + onTap: () async { 743 + final wasSubscribed = isSubscribed; 744 + try { 745 + await provider.toggleSubscription( 746 + communityDid: _community!.did, 747 + ); 748 + await _loadCommunity(); 749 + } on Exception catch (e) { 750 + if (kDebugMode) { 751 + debugPrint('Error toggling subscription: $e'); 752 + } 753 + if (mounted) { 754 + final action = wasSubscribed ? 'leave' : 'join'; 755 + ScaffoldMessenger.of(context).showSnackBar( 756 + SnackBar( 757 + content: Text( 758 + 'Failed to $action community. Please try again.', 759 + ), 760 + behavior: SnackBarBehavior.floating, 761 + backgroundColor: AppColors.primary, 762 + ), 763 + ); 764 + } 765 + } 766 + }, 767 + borderRadius: BorderRadius.circular(14), 768 + child: Container( 769 + padding: const EdgeInsets.symmetric( 770 + horizontal: 10, 771 + vertical: 4, 772 + ), 773 + decoration: BoxDecoration( 774 + borderRadius: BorderRadius.circular(14), 775 + border: Border.all( 776 + color: isSubscribed 777 + ? AppColors.teal 778 + : AppColors.textSecondary.withValues(alpha: 0.5), 779 + width: 1, 780 + ), 781 + ), 782 + child: Row( 783 + mainAxisSize: MainAxisSize.min, 784 + children: [ 785 + Icon( 786 + isSubscribed 787 + ? Icons.check 788 + : Icons.add_circle_outline, 789 + size: 12, 790 + color: isSubscribed 791 + ? AppColors.teal 792 + : AppColors.textSecondary, 793 + ), 794 + const SizedBox(width: 3), 795 + Text( 796 + isSubscribed ? 'Joined' : 'Join', 797 + style: TextStyle( 798 + fontSize: 11, 799 + fontWeight: FontWeight.w600, 800 + color: isSubscribed 801 + ? AppColors.teal 802 + : AppColors.textSecondary, 803 + ), 804 + ), 805 + ], 806 + ), 807 + ), 808 + ), 809 + ), 810 + ); 811 + }, 812 + ); 813 + } 814 + 815 + Widget _buildPostsList() { 816 + // Loading state 817 + if (_isLoadingFeed && _posts.isEmpty) { 818 + return const SliverFillRemaining( 819 + child: Center( 820 + child: CircularProgressIndicator(color: AppColors.primary), 821 + ), 822 + ); 823 + } 824 + 825 + // Error state 826 + if (_feedError != null && _posts.isEmpty) { 827 + return SliverFillRemaining( 828 + child: Center( 829 + child: InlineError( 830 + message: _feedError!, 831 + onRetry: () => _loadFeed(refresh: true), 832 + ), 833 + ), 834 + ); 835 + } 836 + 837 + // Empty state 838 + if (_posts.isEmpty && !_isLoadingFeed) { 839 + return SliverFillRemaining( 840 + child: _buildEmptyPostsState(), 841 + ); 842 + } 843 + 844 + // Posts list with loading indicator 845 + final showLoadingSlot = _isLoadingMore || _loadMoreError != null; 846 + 847 + return SliverList( 848 + delegate: SliverChildBuilderDelegate( 849 + (context, index) { 850 + if (index == _posts.length) { 851 + if (_isLoadingMore) { 852 + return const InlineLoading(); 853 + } 854 + if (_loadMoreError != null) { 855 + return InlineError( 856 + message: _loadMoreError!, 857 + onRetry: () { 858 + _clearLoadMoreError(); 859 + _loadMore(); 860 + }, 861 + ); 862 + } 863 + if (!_hasMore && _posts.isNotEmpty) { 864 + return _buildEndOfFeed(); 865 + } 866 + return const SizedBox(height: 80); 867 + } 868 + 869 + final post = _posts[index]; 870 + return RepaintBoundary( 871 + key: ValueKey(post.post.uri), 872 + child: PostCard( 873 + post: post, 874 + currentTime: _currentTime, 875 + showHeader: true, 876 + ), 877 + ); 878 + }, 879 + childCount: _posts.length + (showLoadingSlot || !_hasMore ? 1 : 0), 880 + ), 881 + ); 882 + } 883 + 884 + Widget _buildEmptyPostsState() { 885 + return Center( 886 + child: Padding( 887 + padding: const EdgeInsets.all(32), 888 + child: Column( 889 + mainAxisAlignment: MainAxisAlignment.center, 890 + children: [ 891 + Container( 892 + width: 80, 893 + height: 80, 894 + decoration: BoxDecoration( 895 + color: AppColors.teal.withValues(alpha: 0.1), 896 + borderRadius: BorderRadius.circular(20), 897 + ), 898 + child: const Icon( 899 + Icons.article_outlined, 900 + size: 40, 901 + color: AppColors.teal, 902 + ), 903 + ), 904 + const SizedBox(height: 24), 905 + const Text( 906 + 'No posts yet', 907 + style: TextStyle( 908 + fontSize: 20, 909 + color: AppColors.textPrimary, 910 + fontWeight: FontWeight.bold, 911 + ), 912 + ), 913 + const SizedBox(height: 8), 914 + Text( 915 + 'Be the first to share something in ${_community?.displayName ?? _community?.name ?? 'this community'}!', 916 + style: const TextStyle( 917 + fontSize: 14, 918 + color: AppColors.textSecondary, 919 + height: 1.4, 920 + ), 921 + textAlign: TextAlign.center, 922 + ), 923 + ], 924 + ), 925 + ), 926 + ); 927 + } 928 + 929 + Widget _buildEndOfFeed() { 930 + return Padding( 931 + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 16), 932 + child: Column( 933 + children: [ 934 + Container( 935 + width: 48, 936 + height: 48, 937 + decoration: BoxDecoration( 938 + color: AppColors.teal.withValues(alpha: 0.1), 939 + shape: BoxShape.circle, 940 + ), 941 + child: const Icon( 942 + Icons.check_rounded, 943 + color: AppColors.teal, 944 + size: 24, 945 + ), 946 + ), 947 + const SizedBox(height: 12), 948 + const Text( 949 + "You're all caught up!", 950 + style: TextStyle( 951 + color: AppColors.textSecondary, 952 + fontSize: 14, 953 + fontWeight: FontWeight.w500, 954 + ), 955 + ), 956 + ], 957 + ), 958 + ); 959 + } 960 + 961 + Widget _buildAboutSection() { 962 + if (_community == null) { 963 + return const SliverFillRemaining( 964 + child: Center( 965 + child: CircularProgressIndicator(color: AppColors.primary), 966 + ), 967 + ); 968 + } 969 + 970 + return SliverToBoxAdapter( 971 + child: Padding( 972 + padding: const EdgeInsets.all(20), 973 + child: Column( 974 + crossAxisAlignment: CrossAxisAlignment.start, 975 + children: [ 976 + // Description section 977 + if (_community!.description != null && 978 + _community!.description!.isNotEmpty) ...[ 979 + const _SectionHeader(title: 'About'), 980 + const SizedBox(height: 12), 981 + Container( 982 + padding: const EdgeInsets.all(16), 983 + decoration: BoxDecoration( 984 + color: AppColors.backgroundSecondary, 985 + borderRadius: BorderRadius.circular(12), 986 + border: Border.all(color: AppColors.border), 987 + ), 988 + child: Text( 989 + _community!.description!, 990 + style: const TextStyle( 991 + fontSize: 15, 992 + color: AppColors.textPrimary, 993 + height: 1.5, 994 + ), 995 + ), 996 + ), 997 + const SizedBox(height: 24), 998 + ], 999 + // Stats section 1000 + const _SectionHeader(title: 'Community Stats'), 1001 + const SizedBox(height: 12), 1002 + Container( 1003 + padding: const EdgeInsets.all(16), 1004 + decoration: BoxDecoration( 1005 + color: AppColors.backgroundSecondary, 1006 + borderRadius: BorderRadius.circular(12), 1007 + border: Border.all(color: AppColors.border), 1008 + ), 1009 + child: Column( 1010 + children: [ 1011 + if (_community!.subscriberCount != null) 1012 + _AboutStatRow( 1013 + icon: Icons.notifications_active_outlined, 1014 + label: 'Subscribers', 1015 + value: _formatCount(_community!.subscriberCount!), 1016 + ), 1017 + if (_community!.memberCount != null) ...[ 1018 + const SizedBox(height: 12), 1019 + _AboutStatRow( 1020 + icon: Icons.group_outlined, 1021 + label: 'Members', 1022 + value: _formatCount(_community!.memberCount!), 1023 + ), 1024 + ], 1025 + ], 1026 + ), 1027 + ), 1028 + const SizedBox(height: 24), 1029 + // Community info 1030 + const _SectionHeader(title: 'Info'), 1031 + const SizedBox(height: 12), 1032 + Container( 1033 + padding: const EdgeInsets.all(16), 1034 + decoration: BoxDecoration( 1035 + color: AppColors.backgroundSecondary, 1036 + borderRadius: BorderRadius.circular(12), 1037 + border: Border.all(color: AppColors.border), 1038 + ), 1039 + child: Column( 1040 + children: [ 1041 + _AboutStatRow( 1042 + icon: _community!.visibility == 'public' 1043 + ? Icons.public 1044 + : Icons.lock_outline, 1045 + label: 'Visibility', 1046 + value: _community!.visibility == 'public' 1047 + ? 'Public' 1048 + : 'Private', 1049 + ), 1050 + const SizedBox(height: 12), 1051 + _AboutStatRow( 1052 + icon: Icons.qr_code_2, 1053 + label: 'DID', 1054 + value: _community!.did, 1055 + isMonospace: true, 1056 + ), 1057 + ], 1058 + ), 1059 + ), 1060 + const SizedBox(height: 80), 1061 + ], 1062 + ), 1063 + ), 1064 + ); 1065 + } 1066 + 1067 + String _formatCount(int count) => DisplayUtils.formatCount(count); 1068 + } 1069 + 1070 + /// Tab bar for community content 1071 + class _CommunityTabBar extends StatelessWidget { 1072 + const _CommunityTabBar({ 1073 + required this.selectedIndex, 1074 + required this.onTabChanged, 1075 + }); 1076 + 1077 + final int selectedIndex; 1078 + final ValueChanged<int> onTabChanged; 1079 + 1080 + @override 1081 + Widget build(BuildContext context) { 1082 + return Container( 1083 + height: 48, 1084 + decoration: const BoxDecoration( 1085 + border: Border(bottom: BorderSide(color: AppColors.border)), 1086 + ), 1087 + child: Row( 1088 + children: [ 1089 + Expanded( 1090 + child: _TabItem( 1091 + label: 'Feed', 1092 + icon: Icons.grid_view, 1093 + isSelected: selectedIndex == 0, 1094 + onTap: () => onTabChanged(0), 1095 + ), 1096 + ), 1097 + Expanded( 1098 + child: _TabItem( 1099 + label: 'About', 1100 + icon: Icons.info_outline, 1101 + isSelected: selectedIndex == 1, 1102 + onTap: () => onTabChanged(1), 1103 + ), 1104 + ), 1105 + ], 1106 + ), 1107 + ); 1108 + } 1109 + } 1110 + 1111 + class _TabItem extends StatelessWidget { 1112 + const _TabItem({ 1113 + required this.label, 1114 + required this.icon, 1115 + required this.isSelected, 1116 + required this.onTap, 1117 + }); 1118 + 1119 + final String label; 1120 + final IconData icon; 1121 + final bool isSelected; 1122 + final VoidCallback onTap; 1123 + 1124 + @override 1125 + Widget build(BuildContext context) { 1126 + return GestureDetector( 1127 + onTap: onTap, 1128 + behavior: HitTestBehavior.opaque, 1129 + child: Container( 1130 + alignment: Alignment.center, 1131 + child: Column( 1132 + mainAxisAlignment: MainAxisAlignment.center, 1133 + children: [ 1134 + Row( 1135 + mainAxisSize: MainAxisSize.min, 1136 + children: [ 1137 + Icon( 1138 + icon, 1139 + size: 16, 1140 + color: isSelected 1141 + ? AppColors.textPrimary 1142 + : AppColors.textSecondary, 1143 + ), 1144 + const SizedBox(width: 6), 1145 + Text( 1146 + label, 1147 + style: TextStyle( 1148 + fontSize: 13, 1149 + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, 1150 + color: isSelected 1151 + ? AppColors.textPrimary 1152 + : AppColors.textSecondary, 1153 + ), 1154 + ), 1155 + ], 1156 + ), 1157 + const SizedBox(height: 8), 1158 + Container( 1159 + height: 3, 1160 + width: 50, 1161 + decoration: BoxDecoration( 1162 + color: isSelected ? AppColors.teal : Colors.transparent, 1163 + borderRadius: BorderRadius.circular(2), 1164 + ), 1165 + ), 1166 + ], 1167 + ), 1168 + ), 1169 + ); 1170 + } 1171 + } 1172 + 1173 + /// Delegate for pinned tab bar header 1174 + class _CommunityTabBarDelegate extends SliverPersistentHeaderDelegate { 1175 + _CommunityTabBarDelegate({required this.child}); 1176 + 1177 + final Widget child; 1178 + 1179 + @override 1180 + Widget build( 1181 + BuildContext context, 1182 + double shrinkOffset, 1183 + bool overlapsContent, 1184 + ) { 1185 + return child; 1186 + } 1187 + 1188 + @override 1189 + double get maxExtent => 48; 1190 + 1191 + @override 1192 + double get minExtent => 48; 1193 + 1194 + @override 1195 + bool shouldRebuild(covariant _CommunityTabBarDelegate oldDelegate) { 1196 + return child != oldDelegate.child; 1197 + } 1198 + } 1199 + 1200 + /// Delegate for pinned feed sort header 1201 + class _FeedSortDelegate extends SliverPersistentHeaderDelegate { 1202 + _FeedSortDelegate({required this.child}); 1203 + 1204 + final Widget child; 1205 + 1206 + @override 1207 + Widget build( 1208 + BuildContext context, 1209 + double shrinkOffset, 1210 + bool overlapsContent, 1211 + ) { 1212 + return child; 1213 + } 1214 + 1215 + @override 1216 + double get maxExtent => 56; 1217 + 1218 + @override 1219 + double get minExtent => 56; 1220 + 1221 + @override 1222 + bool shouldRebuild(covariant _FeedSortDelegate oldDelegate) { 1223 + return child != oldDelegate.child; 1224 + } 1225 + } 1226 + 1227 + /// Section header for About tab 1228 + class _SectionHeader extends StatelessWidget { 1229 + const _SectionHeader({required this.title}); 1230 + 1231 + final String title; 1232 + 1233 + @override 1234 + Widget build(BuildContext context) { 1235 + return Text( 1236 + title, 1237 + style: const TextStyle( 1238 + fontSize: 12, 1239 + fontWeight: FontWeight.w600, 1240 + color: AppColors.textSecondary, 1241 + letterSpacing: 1.2, 1242 + ), 1243 + ); 1244 + } 1245 + } 1246 + 1247 + /// Stat row for About tab 1248 + class _AboutStatRow extends StatelessWidget { 1249 + const _AboutStatRow({ 1250 + required this.icon, 1251 + required this.label, 1252 + required this.value, 1253 + this.isMonospace = false, 1254 + }); 1255 + 1256 + final IconData icon; 1257 + final String label; 1258 + final String value; 1259 + final bool isMonospace; 1260 + 1261 + @override 1262 + Widget build(BuildContext context) { 1263 + return Row( 1264 + children: [ 1265 + Icon( 1266 + icon, 1267 + size: 18, 1268 + color: AppColors.teal, 1269 + ), 1270 + const SizedBox(width: 12), 1271 + Expanded( 1272 + child: Column( 1273 + crossAxisAlignment: CrossAxisAlignment.start, 1274 + children: [ 1275 + Text( 1276 + label, 1277 + style: const TextStyle( 1278 + fontSize: 12, 1279 + color: AppColors.textSecondary, 1280 + ), 1281 + ), 1282 + const SizedBox(height: 2), 1283 + Text( 1284 + value, 1285 + style: TextStyle( 1286 + fontSize: 14, 1287 + color: AppColors.textPrimary, 1288 + fontWeight: FontWeight.w500, 1289 + fontFamily: isMonospace ? 'monospace' : null, 1290 + ), 1291 + overflow: TextOverflow.ellipsis, 1292 + ), 1293 + ], 1294 + ), 1295 + ), 1296 + ], 1297 + ); 1298 + } 1299 + } 1300 + 1301 + /// Chip button for feed sort selection 1302 + class _FeedSortChip extends StatelessWidget { 1303 + const _FeedSortChip({ 1304 + required this.label, 1305 + required this.icon, 1306 + required this.isSelected, 1307 + required this.onTap, 1308 + }); 1309 + 1310 + final String label; 1311 + final IconData icon; 1312 + final bool isSelected; 1313 + final VoidCallback onTap; 1314 + 1315 + @override 1316 + Widget build(BuildContext context) { 1317 + return GestureDetector( 1318 + onTap: onTap, 1319 + child: AnimatedContainer( 1320 + duration: const Duration(milliseconds: 200), 1321 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), 1322 + decoration: BoxDecoration( 1323 + color: isSelected 1324 + ? AppColors.teal.withValues(alpha: 0.15) 1325 + : Colors.transparent, 1326 + borderRadius: BorderRadius.circular(16), 1327 + border: Border.all( 1328 + color: isSelected ? AppColors.teal : AppColors.border, 1329 + width: 1, 1330 + ), 1331 + ), 1332 + child: Row( 1333 + mainAxisSize: MainAxisSize.min, 1334 + children: [ 1335 + Icon( 1336 + icon, 1337 + size: 14, 1338 + color: isSelected ? AppColors.teal : AppColors.textSecondary, 1339 + ), 1340 + const SizedBox(width: 4), 1341 + Text( 1342 + label, 1343 + style: TextStyle( 1344 + fontSize: 13, 1345 + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, 1346 + color: isSelected ? AppColors.teal : AppColors.textSecondary, 1347 + ), 1348 + ), 1349 + ], 1350 + ), 1351 + ), 1352 + ); 1353 + } 1354 + } 1355 +
+9 -5
lib/screens/home/post_detail_screen.dart
··· 11 11 import '../../providers/comments_provider.dart'; 12 12 import '../../providers/vote_provider.dart'; 13 13 import '../../services/comments_provider_cache.dart'; 14 + import '../../utils/display_utils.dart'; 14 15 import '../../utils/error_messages.dart'; 15 16 import '../../widgets/comment_thread.dart'; 16 17 import '../../widgets/comments_header.dart'; ··· 386 387 return _buildFallbackAvatar(community, size); 387 388 } 388 389 389 - /// Build fallback avatar with first letter 390 + /// Build fallback avatar with first letter and hash-based color 390 391 Widget _buildFallbackAvatar(CommunityRef community, double size) { 391 - final firstLetter = community.name.isNotEmpty ? community.name[0] : '?'; 392 + final name = community.name; 393 + final firstLetter = name.isNotEmpty ? name[0].toUpperCase() : 'C'; 394 + final bgColor = DisplayUtils.getFallbackColor(name); 395 + 392 396 return Container( 393 397 width: size, 394 398 height: size, 395 399 decoration: BoxDecoration( 396 - color: AppColors.communityName.withValues(alpha: 0.2), 400 + color: bgColor, 397 401 shape: BoxShape.circle, 398 402 ), 399 403 child: Center( 400 404 child: Text( 401 - firstLetter.toUpperCase(), 405 + firstLetter, 402 406 style: TextStyle( 403 - color: AppColors.communityName, 407 + color: Colors.white, 404 408 fontSize: size * 0.45, 405 409 fontWeight: FontWeight.bold, 406 410 ),
+98
lib/services/coves_api_service.dart
··· 321 321 } 322 322 } 323 323 324 + /// Get community feed (public, no auth required) 325 + /// 326 + /// Fetches posts from a specific community. 327 + /// Does not require authentication but optionally includes voter state 328 + /// when authenticated. 329 + /// 330 + /// Parameters: 331 + /// - [community]: Community DID or handle (required) 332 + /// - [sort]: 'hot', 'top', or 'new' (default: 'hot') 333 + /// - [timeframe]: 'hour', 'day', 'week', 'month', 'year', 'all' 334 + /// (default: 'day' for top sort) 335 + /// - [limit]: Number of posts per page (default: 15, max: 50) 336 + /// - [cursor]: Pagination cursor from previous response 337 + Future<TimelineResponse> getCommunityFeed({ 338 + required String community, 339 + String sort = 'hot', 340 + String? timeframe, 341 + int limit = 15, 342 + String? cursor, 343 + }) async { 344 + try { 345 + if (kDebugMode) { 346 + debugPrint( 347 + '📡 Fetching community feed: community=$community, ' 348 + 'sort=$sort, limit=$limit', 349 + ); 350 + } 351 + 352 + final queryParams = <String, dynamic>{ 353 + 'community': community, 354 + 'sort': sort, 355 + 'limit': limit, 356 + }; 357 + 358 + if (timeframe != null) { 359 + queryParams['timeframe'] = timeframe; 360 + } 361 + 362 + if (cursor != null) { 363 + queryParams['cursor'] = cursor; 364 + } 365 + 366 + final response = await _dio.get( 367 + '/xrpc/social.coves.communityFeed.getCommunity', 368 + queryParameters: queryParams, 369 + ); 370 + 371 + if (kDebugMode) { 372 + debugPrint( 373 + '✅ Community feed fetched: ' 374 + '${response.data['feed']?.length ?? 0} posts', 375 + ); 376 + } 377 + 378 + return TimelineResponse.fromJson(response.data as Map<String, dynamic>); 379 + } on DioException catch (e) { 380 + _handleDioException(e, 'community feed'); 381 + } catch (e) { 382 + if (kDebugMode) { 383 + debugPrint('❌ Error parsing community feed response: $e'); 384 + } 385 + throw ApiException('Failed to parse server response', originalError: e); 386 + } 387 + } 388 + 324 389 /// Get comments for a post (authenticated) 325 390 /// 326 391 /// Fetches threaded comments for a specific post. ··· 438 503 } catch (e) { 439 504 if (kDebugMode) { 440 505 debugPrint('❌ Error parsing communities response: $e'); 506 + } 507 + throw ApiException('Failed to parse server response', originalError: e); 508 + } 509 + } 510 + 511 + /// Get a single community by identifier 512 + /// 513 + /// Fetches community details by DID or handle. 514 + /// Does not require authentication. 515 + /// 516 + /// Parameters: 517 + /// - [community]: Community DID or handle (required) 518 + Future<CommunityView> getCommunity({required String community}) async { 519 + try { 520 + if (kDebugMode) { 521 + debugPrint('📡 Fetching community: $community'); 522 + } 523 + 524 + final response = await _dio.get( 525 + '/xrpc/social.coves.community.get', 526 + queryParameters: {'community': community}, 527 + ); 528 + 529 + if (kDebugMode) { 530 + debugPrint('✅ Community fetched: ${response.data['name']}'); 531 + } 532 + 533 + return CommunityView.fromJson(response.data as Map<String, dynamic>); 534 + } on DioException catch (e) { 535 + _handleDioException(e, 'community'); 536 + } catch (e) { 537 + if (kDebugMode) { 538 + debugPrint('❌ Error parsing community response: $e'); 441 539 } 442 540 throw ApiException('Failed to parse server response', originalError: e); 443 541 }
+49
lib/utils/display_utils.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + import '../constants/app_colors.dart'; 4 + 5 + /// Utility class for shared display formatting and styling 6 + /// 7 + /// Centralizes common display logic to avoid duplication across widgets: 8 + /// - Avatar fallback colors (consistent color generation by name hash) 9 + /// - Number formatting (K/M suffixes for large numbers) 10 + class DisplayUtils { 11 + DisplayUtils._(); 12 + 13 + /// Fallback colors for avatars when no image is available 14 + /// 15 + /// Used by community avatars, user avatars, and other fallback displays. 16 + /// Color is deterministically selected based on name hash for consistency. 17 + static const fallbackColors = [ 18 + AppColors.coral, 19 + AppColors.teal, 20 + Color(0xFF9B59B6), // Purple 21 + Color(0xFF3498DB), // Blue 22 + Color(0xFF27AE60), // Green 23 + Color(0xFFE74C3C), // Red 24 + ]; 25 + 26 + /// Get a consistent fallback color for a given name 27 + /// 28 + /// Uses hash code to deterministically select a color from [fallbackColors]. 29 + /// The same name will always return the same color. 30 + static Color getFallbackColor(String name) { 31 + final colorIndex = name.hashCode.abs() % fallbackColors.length; 32 + return fallbackColors[colorIndex]; 33 + } 34 + 35 + /// Format a number with K/M suffixes for compact display 36 + /// 37 + /// Examples: 38 + /// - 500 -> "500" 39 + /// - 1,234 -> "1.2K" 40 + /// - 1,500,000 -> "1.5M" 41 + static String formatCount(int count) { 42 + if (count >= 1000000) { 43 + return '${(count / 1000000).toStringAsFixed(1)}M'; 44 + } else if (count >= 1000) { 45 + return '${(count / 1000).toStringAsFixed(1)}K'; 46 + } 47 + return count.toString(); 48 + } 49 + }
+313
lib/widgets/community_header.dart
··· 1 + import 'package:cached_network_image/cached_network_image.dart'; 2 + import 'package:flutter/foundation.dart'; 3 + import 'package:flutter/material.dart'; 4 + 5 + import '../constants/app_colors.dart'; 6 + import '../models/community.dart'; 7 + import '../utils/community_handle_utils.dart'; 8 + import '../utils/display_utils.dart'; 9 + 10 + /// Community header widget displaying banner, avatar, and community info 11 + /// 12 + /// Layout matches the profile header design pattern: 13 + /// - Full-width banner image with gradient overlay 14 + /// - Circular avatar with shadow 15 + /// - Community name, handle, and description 16 + /// - Stats row showing subscriber/member counts 17 + class CommunityHeader extends StatelessWidget { 18 + const CommunityHeader({ 19 + required this.community, 20 + super.key, 21 + }); 22 + 23 + final CommunityView? community; 24 + 25 + static const double bannerHeight = 150; 26 + 27 + @override 28 + Widget build(BuildContext context) { 29 + final isIOS = Theme.of(context).platform == TargetPlatform.iOS; 30 + 31 + return Stack( 32 + children: [ 33 + // Banner image (or decorative fallback) 34 + _buildBannerImage(), 35 + // Gradient overlay for text readability 36 + Positioned.fill( 37 + child: Container( 38 + decoration: BoxDecoration( 39 + gradient: LinearGradient( 40 + begin: Alignment.topCenter, 41 + end: Alignment.bottomCenter, 42 + colors: [ 43 + Colors.transparent, 44 + AppColors.background.withValues(alpha: isIOS ? 0.6 : 0.3), 45 + AppColors.background, 46 + ], 47 + stops: isIOS 48 + ? const [0.0, 0.25, 0.55] 49 + : const [0.0, 0.5, 1.0], 50 + ), 51 + ), 52 + ), 53 + ), 54 + // Community content 55 + SafeArea( 56 + bottom: false, 57 + child: Padding( 58 + padding: const EdgeInsets.only(top: kToolbarHeight), 59 + child: UnconstrainedBox( 60 + clipBehavior: Clip.hardEdge, 61 + alignment: Alignment.topLeft, 62 + constrainedAxis: Axis.horizontal, 63 + child: Column( 64 + crossAxisAlignment: CrossAxisAlignment.start, 65 + mainAxisSize: MainAxisSize.min, 66 + children: [ 67 + // Avatar and name row 68 + _buildAvatarAndNameRow(), 69 + // Description 70 + if (community?.description != null && 71 + community!.description!.isNotEmpty) ...[ 72 + const SizedBox(height: 4), 73 + Padding( 74 + padding: const EdgeInsets.symmetric(horizontal: 16), 75 + child: Text( 76 + community!.description!, 77 + style: const TextStyle( 78 + fontSize: 14, 79 + color: AppColors.textPrimary, 80 + height: 1.4, 81 + ), 82 + maxLines: 2, 83 + overflow: TextOverflow.ellipsis, 84 + ), 85 + ), 86 + ], 87 + // Stats row 88 + const SizedBox(height: 12), 89 + Padding( 90 + padding: const EdgeInsets.symmetric(horizontal: 16), 91 + child: _buildStatsRow(), 92 + ), 93 + ], 94 + ), 95 + ), 96 + ), 97 + ), 98 + ], 99 + ); 100 + } 101 + 102 + Widget _buildBannerImage() { 103 + // Communities don't have banners yet, so we use a decorative pattern 104 + // that varies based on community name for visual distinction 105 + return _buildDefaultBanner(); 106 + } 107 + 108 + Widget _buildDefaultBanner() { 109 + // Use hash-based color matching the fallback avatar 110 + final name = community?.name ?? ''; 111 + final baseColor = DisplayUtils.getFallbackColor(name); 112 + 113 + return Container( 114 + height: bannerHeight, 115 + width: double.infinity, 116 + decoration: BoxDecoration( 117 + gradient: LinearGradient( 118 + begin: Alignment.topLeft, 119 + end: Alignment.bottomRight, 120 + colors: [ 121 + baseColor.withValues(alpha: 0.6), 122 + baseColor.withValues(alpha: 0.3), 123 + ], 124 + ), 125 + ), 126 + ); 127 + } 128 + 129 + Widget _buildAvatarAndNameRow() { 130 + const avatarSize = 80.0; 131 + 132 + return Padding( 133 + padding: const EdgeInsets.symmetric(horizontal: 16), 134 + child: Row( 135 + crossAxisAlignment: CrossAxisAlignment.start, 136 + children: [ 137 + // Circular avatar (matches profile style) 138 + Container( 139 + width: avatarSize, 140 + height: avatarSize, 141 + decoration: BoxDecoration( 142 + shape: BoxShape.circle, 143 + border: Border.all( 144 + color: AppColors.background, 145 + width: 3, 146 + ), 147 + boxShadow: [ 148 + BoxShadow( 149 + color: Colors.black.withValues(alpha: 0.3), 150 + blurRadius: 8, 151 + offset: const Offset(0, 2), 152 + spreadRadius: 1, 153 + ), 154 + ], 155 + ), 156 + child: ClipOval( 157 + child: _buildAvatar(avatarSize - 6), 158 + ), 159 + ), 160 + const SizedBox(width: 12), 161 + // Name and handle column 162 + Expanded( 163 + child: Column( 164 + crossAxisAlignment: CrossAxisAlignment.start, 165 + children: [ 166 + const SizedBox(height: 4), 167 + // Display name 168 + Text( 169 + community?.displayName ?? community?.name ?? 'Loading...', 170 + style: const TextStyle( 171 + fontSize: 20, 172 + fontWeight: FontWeight.bold, 173 + color: AppColors.textPrimary, 174 + letterSpacing: -0.3, 175 + ), 176 + maxLines: 1, 177 + overflow: TextOverflow.ellipsis, 178 + ), 179 + // Handle 180 + if (community?.handle != null) ...[ 181 + const SizedBox(height: 2), 182 + Text( 183 + CommunityHandleUtils.formatHandleForDisplay( 184 + community!.handle, 185 + ) ?? 186 + '', 187 + style: const TextStyle( 188 + fontSize: 14, 189 + color: AppColors.teal, 190 + fontWeight: FontWeight.w500, 191 + ), 192 + ), 193 + ], 194 + ], 195 + ), 196 + ), 197 + ], 198 + ), 199 + ); 200 + } 201 + 202 + Widget _buildAvatar(double size) { 203 + if (community?.avatar != null && community!.avatar!.isNotEmpty) { 204 + return CachedNetworkImage( 205 + imageUrl: community!.avatar!, 206 + width: size, 207 + height: size, 208 + fit: BoxFit.cover, 209 + fadeInDuration: Duration.zero, 210 + fadeOutDuration: Duration.zero, 211 + placeholder: (context, url) => _buildAvatarLoading(size), 212 + errorWidget: (context, url, error) { 213 + if (kDebugMode) { 214 + debugPrint( 215 + 'Error loading community avatar for ${community?.name}: $error', 216 + ); 217 + } 218 + return _buildFallbackAvatar(size); 219 + }, 220 + ); 221 + } 222 + return _buildFallbackAvatar(size); 223 + } 224 + 225 + Widget _buildAvatarLoading(double size) { 226 + return Container( 227 + width: size, 228 + height: size, 229 + color: AppColors.backgroundSecondary, 230 + ); 231 + } 232 + 233 + Widget _buildFallbackAvatar(double size) { 234 + final name = community?.name ?? ''; 235 + final bgColor = DisplayUtils.getFallbackColor(name); 236 + 237 + return Container( 238 + width: size, 239 + height: size, 240 + color: bgColor, 241 + child: Center( 242 + child: Text( 243 + name.isNotEmpty ? name[0].toUpperCase() : 'C', 244 + style: TextStyle( 245 + fontSize: size * 0.45, 246 + fontWeight: FontWeight.bold, 247 + color: Colors.white, 248 + letterSpacing: -1, 249 + ), 250 + ), 251 + ), 252 + ); 253 + } 254 + 255 + Widget _buildStatsRow() { 256 + return Wrap( 257 + spacing: 16, 258 + runSpacing: 8, 259 + children: [ 260 + if (community?.subscriberCount != null) 261 + _StatItem( 262 + label: 'Subscribers', 263 + value: community!.subscriberCount!, 264 + ), 265 + if (community?.memberCount != null) 266 + _StatItem( 267 + label: 'Members', 268 + value: community!.memberCount!, 269 + ), 270 + ], 271 + ); 272 + } 273 + 274 + } 275 + 276 + /// Stats item showing label and value (matches profile pattern) 277 + class _StatItem extends StatelessWidget { 278 + const _StatItem({ 279 + required this.label, 280 + required this.value, 281 + }); 282 + 283 + final String label; 284 + final int value; 285 + 286 + @override 287 + Widget build(BuildContext context) { 288 + final valueText = DisplayUtils.formatCount(value); 289 + 290 + return RichText( 291 + text: TextSpan( 292 + children: [ 293 + TextSpan( 294 + text: valueText, 295 + style: const TextStyle( 296 + fontSize: 14, 297 + fontWeight: FontWeight.bold, 298 + color: AppColors.textPrimary, 299 + ), 300 + ), 301 + TextSpan( 302 + text: ' $label', 303 + style: const TextStyle( 304 + fontSize: 14, 305 + color: AppColors.textSecondary, 306 + ), 307 + ), 308 + ], 309 + ), 310 + ); 311 + } 312 + } 313 +
+12 -4
lib/widgets/post_card.dart
··· 16 16 import 'rich_text_renderer.dart'; 17 17 import 'source_link_bar.dart'; 18 18 import 'tappable_author.dart'; 19 + import 'tappable_community.dart'; 19 20 20 21 /// Post card widget for displaying feed posts 21 22 /// ··· 95 96 if (showHeader) ...[ 96 97 Row( 97 98 children: [ 98 - // Community avatar 99 - _buildCommunityAvatar(post.post.community), 99 + // Community avatar (tappable for community navigation) 100 + TappableCommunity( 101 + communityDid: post.post.community.did, 102 + child: _buildCommunityAvatar(post.post.community), 103 + ), 100 104 const SizedBox(width: 8), 101 105 Expanded( 102 106 child: Column( 103 107 crossAxisAlignment: CrossAxisAlignment.start, 104 108 children: [ 105 - // Community handle with styled parts 106 - _buildCommunityHandle(post.post.community), 109 + // Community handle with styled parts (tappable) 110 + TappableCommunity( 111 + communityDid: post.post.community.did, 112 + padding: const EdgeInsets.symmetric(vertical: 2), 113 + child: _buildCommunityHandle(post.post.community), 114 + ), 107 115 // Author handle (tappable for profile navigation) 108 116 TappableAuthor( 109 117 authorDid: post.post.author.did,
+51
lib/widgets/tappable_community.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:go_router/go_router.dart'; 3 + 4 + /// Wraps a child widget to make it navigate to a community's feed on tap. 5 + /// 6 + /// This widget encapsulates the common pattern of tapping a community's avatar 7 + /// or name to navigate to its feed page. It handles the InkWell styling 8 + /// and navigation logic. 9 + /// 10 + /// Example: 11 + /// ```dart 12 + /// TappableCommunity( 13 + /// communityDid: post.community.did, 14 + /// child: Row( 15 + /// children: [ 16 + /// CommunityAvatar(community: post.community), 17 + /// Text(post.community.name), 18 + /// ], 19 + /// ), 20 + /// ) 21 + /// ``` 22 + class TappableCommunity extends StatelessWidget { 23 + const TappableCommunity({ 24 + required this.communityDid, 25 + required this.child, 26 + this.borderRadius = 4.0, 27 + this.padding = EdgeInsets.zero, 28 + super.key, 29 + }); 30 + 31 + /// The DID of the community to navigate to 32 + final String communityDid; 33 + 34 + /// The child widget to wrap (typically avatar + name row) 35 + final Widget child; 36 + 37 + /// Border radius for the InkWell splash effect 38 + final double borderRadius; 39 + 40 + /// Padding around the child 41 + final EdgeInsetsGeometry padding; 42 + 43 + @override 44 + Widget build(BuildContext context) { 45 + return InkWell( 46 + onTap: () => context.push('/community/$communityDid'), 47 + borderRadius: BorderRadius.circular(borderRadius), 48 + child: Padding(padding: padding, child: child), 49 + ); 50 + } 51 + }