From f8481fd70b402dc8670851dcd49206b647b59fbb Mon Sep 17 00:00:00 2001 From: Bretton Date: Fri, 5 Dec 2025 15:56:19 -0800 Subject: [PATCH] feat(feed): add TikTok-style feed type selector and communities tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FeedType enum (discover/forYou) with feed switching in FeedProvider - Replace AppBar with transparent gradient header overlay - Add Discover/For You tabs with underline indicator (auth-gated) - Rename Search tab to Communities with Workspaces icon - Use IndexedStack to preserve screen state on tab switch - Add accessibility labels and extract magic numbers to constants 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/providers/feed_provider.dart | 47 ++++- ...ch_screen.dart => communities_screen.dart} | 20 +- lib/screens/home/feed_screen.dart | 176 ++++++++++++++++-- lib/screens/home/main_shell_screen.dart | 37 ++-- 4 files changed, 240 insertions(+), 40 deletions(-) rename lib/screens/home/{search_screen.dart => communities_screen.dart} (68%) diff --git a/lib/providers/feed_provider.dart b/lib/providers/feed_provider.dart index 6a1b72f..fda9cc4 100644 --- a/lib/providers/feed_provider.dart +++ b/lib/providers/feed_provider.dart @@ -6,6 +6,15 @@ import '../services/coves_api_service.dart'; import 'auth_provider.dart'; import 'vote_provider.dart'; +/// Feed types available in the app +enum FeedType { + /// All posts across the network + discover, + + /// Posts from subscribed communities (authenticated only) + forYou, +} + /// Feed Provider /// /// Manages feed state and fetching logic. @@ -53,6 +62,8 @@ class FeedProvider with ChangeNotifier { if (kDebugMode) { debugPrint('🔒 User signed out - clearing feed'); } + // Reset feed type to Discover since For You requires auth + _feedType = FeedType.discover; reset(); // Automatically load the public discover feed loadFeed(refresh: true); @@ -80,6 +91,7 @@ class FeedProvider with ChangeNotifier { // Feed configuration String _sort = 'hot'; String? _timeframe; + FeedType _feedType = FeedType.discover; // Time update mechanism for periodic UI refreshes Timer? _timeUpdateTimer; @@ -94,6 +106,10 @@ class FeedProvider with ChangeNotifier { String get sort => _sort; String? get timeframe => _timeframe; DateTime? get currentTime => _currentTime; + FeedType get feedType => _feedType; + + /// Check if For You feed is available (requires authentication) + bool get isForYouAvailable => _authProvider.isAuthenticated; /// Start periodic time updates for "time ago" strings /// @@ -130,14 +146,13 @@ class FeedProvider with ChangeNotifier { } } - /// Load feed based on authentication state (business logic - /// encapsulation) + /// Load feed based on current feed type /// /// This method encapsulates the business logic of deciding which feed - /// to fetch. Previously this logic was in the UI layer (FeedScreen), - /// violating clean architecture. + /// to fetch based on the selected feed type. Future loadFeed({bool refresh = false}) async { - if (_authProvider.isAuthenticated) { + // For You requires authentication - fall back to Discover if not + if (_feedType == FeedType.forYou && _authProvider.isAuthenticated) { await fetchTimeline(refresh: refresh); } else { await fetchDiscover(refresh: refresh); @@ -149,6 +164,28 @@ class FeedProvider with ChangeNotifier { } } + /// Switch feed type and reload + Future setFeedType(FeedType type) async { + if (_feedType == type) { + return; + } + + // For You requires authentication + if (type == FeedType.forYou && !_authProvider.isAuthenticated) { + return; + } + + _feedType = type; + // Reset pagination state but keep posts visible until new feed loads + _cursor = null; + _hasMore = true; + _error = null; + notifyListeners(); + + // Load new feed - old posts stay visible until new ones arrive + await loadFeed(refresh: true); + } + /// Common feed fetching logic (DRY principle - eliminates code /// duplication) Future _fetchFeed({ diff --git a/lib/screens/home/search_screen.dart b/lib/screens/home/communities_screen.dart similarity index 68% rename from lib/screens/home/search_screen.dart rename to lib/screens/home/communities_screen.dart index cbdfbc3..30b8867 100644 --- a/lib/screens/home/search_screen.dart +++ b/lib/screens/home/communities_screen.dart @@ -2,17 +2,17 @@ import 'package:flutter/material.dart'; import '../../constants/app_colors.dart'; -class SearchScreen extends StatelessWidget { - const SearchScreen({super.key}); +class CommunitiesScreen extends StatelessWidget { + const CommunitiesScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFF0B0F14), + backgroundColor: AppColors.background, appBar: AppBar( - backgroundColor: const Color(0xFF0B0F14), + backgroundColor: AppColors.background, foregroundColor: Colors.white, - title: const Text('Search'), + title: const Text('Communities'), automaticallyImplyLeading: false, ), body: const Center( @@ -21,10 +21,14 @@ class SearchScreen extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.search, size: 64, color: AppColors.primary), + Icon( + Icons.workspaces_outlined, + size: 64, + color: AppColors.primary, + ), SizedBox(height: 24), Text( - 'Search', + 'Communities', style: TextStyle( fontSize: 28, color: Colors.white, @@ -33,7 +37,7 @@ class SearchScreen extends StatelessWidget { ), SizedBox(height: 16), Text( - 'Search communities and conversations', + 'Discover and join communities', style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)), textAlign: TextAlign.center, ), diff --git a/lib/screens/home/feed_screen.dart b/lib/screens/home/feed_screen.dart index ea1e609..72854ef 100644 --- a/lib/screens/home/feed_screen.dart +++ b/lib/screens/home/feed_screen.dart @@ -5,10 +5,20 @@ import '../../constants/app_colors.dart'; import '../../models/post.dart'; import '../../providers/auth_provider.dart'; import '../../providers/feed_provider.dart'; +import '../../widgets/icons/bluesky_icons.dart'; import '../../widgets/post_card.dart'; +/// Header layout constants +const double _kHeaderHeight = 44; +const double _kTabUnderlineWidth = 28; +const double _kTabUnderlineHeight = 3; +const double _kHeaderContentPadding = _kHeaderHeight; + class FeedScreen extends StatefulWidget { - const FeedScreen({super.key}); + const FeedScreen({super.key, this.onSearchTap}); + + /// Callback when search icon is tapped (to switch to communities tab) + final VoidCallback? onSearchTap; @override State createState() => _FeedScreenState(); @@ -63,6 +73,9 @@ class _FeedScreenState extends State { ); final isLoading = context.select((p) => p.isLoading); final error = context.select((p) => p.error); + final feedType = context.select( + (p) => p.feedType, + ); // IMPORTANT: This relies on FeedProvider creating new list instances // (_posts = [..._posts, ...response.feed]) rather than mutating in-place. @@ -80,25 +93,158 @@ class _FeedScreenState extends State { return Scaffold( backgroundColor: AppColors.background, - appBar: AppBar( - backgroundColor: AppColors.background, - foregroundColor: AppColors.textPrimary, - title: Text(isAuthenticated ? 'Feed' : 'Explore'), - automaticallyImplyLeading: false, - ), body: SafeArea( - child: _buildBody( - isLoading: isLoading, - error: error, - posts: posts, - isLoadingMore: isLoadingMore, - isAuthenticated: isAuthenticated, - currentTime: currentTime, + child: Stack( + children: [ + // Feed content (behind header) + _buildBody( + isLoading: isLoading, + error: error, + posts: posts, + isLoadingMore: isLoadingMore, + isAuthenticated: isAuthenticated, + currentTime: currentTime, + ), + // Transparent header overlay + _buildHeader( + feedType: feedType, + isAuthenticated: isAuthenticated, + ), + ], + ), + ), + ); + } + + Widget _buildHeader({ + required FeedType feedType, + required bool isAuthenticated, + }) { + return Container( + height: _kHeaderHeight, + decoration: BoxDecoration( + // Gradient fade from solid to transparent + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.background, + AppColors.background.withValues(alpha: 0.8), + AppColors.background.withValues(alpha: 0), + ], + stops: const [0.0, 0.6, 1.0], + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + // Feed type tabs in the center + Expanded( + child: _buildFeedTypeTabs( + feedType: feedType, + isAuthenticated: isAuthenticated, + ), + ), + // Search/Communities icon on the right + if (widget.onSearchTap != null) + Semantics( + label: 'Navigate to Communities', + button: true, + child: InkWell( + onTap: widget.onSearchTap, + borderRadius: BorderRadius.circular(20), + splashColor: AppColors.primary.withValues(alpha: 0.2), + child: Padding( + padding: const EdgeInsets.all(8), + child: BlueSkyIcon.search(color: AppColors.textPrimary), + ), + ), + ), + ], + ), + ); + } + + Widget _buildFeedTypeTabs({ + required FeedType feedType, + required bool isAuthenticated, + }) { + // If not authenticated, only show Discover + if (!isAuthenticated) { + return Center( + child: _buildFeedTypeTab( + label: 'Discover', + isActive: true, + onTap: null, + ), + ); + } + + // Authenticated: show both tabs side by side (TikTok style) + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildFeedTypeTab( + label: 'Discover', + isActive: feedType == FeedType.discover, + onTap: () => _switchToFeedType(FeedType.discover), + ), + const SizedBox(width: 24), + _buildFeedTypeTab( + label: 'For You', + isActive: feedType == FeedType.forYou, + onTap: () => _switchToFeedType(FeedType.forYou), + ), + ], + ); + } + + Widget _buildFeedTypeTab({ + required String label, + required bool isActive, + required VoidCallback? onTap, + }) { + return Semantics( + label: '$label feed${isActive ? ', selected' : ''}', + button: true, + selected: isActive, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + style: TextStyle( + color: isActive + ? AppColors.textPrimary + : AppColors.textSecondary.withValues(alpha: 0.6), + fontSize: 16, + fontWeight: isActive ? FontWeight.w700 : FontWeight.w400, + ), + ), + const SizedBox(height: 2), + // Underline indicator (TikTok style) + Container( + width: _kTabUnderlineWidth, + height: _kTabUnderlineHeight, + decoration: BoxDecoration( + color: isActive ? AppColors.textPrimary : Colors.transparent, + borderRadius: BorderRadius.circular(2), + ), + ), + ], ), ), ); } + void _switchToFeedType(FeedType type) { + Provider.of(context, listen: false).setFeedType(type); + } + Widget _buildBody({ required bool isLoading, required String? error, @@ -205,6 +351,8 @@ class _FeedScreenState extends State { color: AppColors.primary, child: ListView.builder( controller: _scrollController, + // Add top padding so content isn't hidden behind transparent header + padding: const EdgeInsets.only(top: _kHeaderContentPadding), // Add extra item for loading indicator or pagination error itemCount: posts.length + (isLoadingMore || error != null ? 1 : 0), itemBuilder: (context, index) { diff --git a/lib/screens/home/main_shell_screen.dart b/lib/screens/home/main_shell_screen.dart index f4b4561..2549598 100644 --- a/lib/screens/home/main_shell_screen.dart +++ b/lib/screens/home/main_shell_screen.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import '../../constants/app_colors.dart'; import '../../widgets/icons/bluesky_icons.dart'; +import 'communities_screen.dart'; import 'create_post_screen.dart'; import 'feed_screen.dart'; import 'notifications_screen.dart'; import 'profile_screen.dart'; -import 'search_screen.dart'; class MainShellScreen extends StatefulWidget { const MainShellScreen({super.key}); @@ -18,24 +18,31 @@ class MainShellScreen extends StatefulWidget { class _MainShellScreenState extends State { int _selectedIndex = 0; - static const List _screens = [ - FeedScreen(), - SearchScreen(), - CreatePostScreen(), - NotificationsScreen(), - ProfileScreen(), - ]; - void _onItemTapped(int index) { setState(() { _selectedIndex = index; }); } + void _onCommunitiesTap() { + setState(() { + _selectedIndex = 1; // Switch to communities tab + }); + } + @override Widget build(BuildContext context) { return Scaffold( - body: _screens[_selectedIndex], + body: IndexedStack( + index: _selectedIndex, + children: [ + FeedScreen(onSearchTap: _onCommunitiesTap), + const CommunitiesScreen(), + const CreatePostScreen(), + const NotificationsScreen(), + const ProfileScreen(), + ], + ), bottomNavigationBar: Container( decoration: const BoxDecoration( color: Color(0xFF0B0F14), @@ -48,7 +55,7 @@ class _MainShellScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _buildNavItem(0, 'home', 'Home'), - _buildNavItem(1, 'search', 'Search'), + _buildNavItem(1, 'communities', 'Communities'), _buildNavItem(2, 'plus', 'Create'), _buildNavItem(3, 'bell', 'Notifications'), _buildNavItem(4, 'person', 'Me'), @@ -73,8 +80,12 @@ class _MainShellScreenState extends State { case 'home': icon = BlueSkyIcon.homeSimple(color: color); break; - case 'search': - icon = BlueSkyIcon.search(color: color); + case 'communities': + icon = Icon( + isSelected ? Icons.workspaces : Icons.workspaces_outlined, + color: color, + size: 24, + ); break; case 'plus': icon = BlueSkyIcon.plus(color: color); -- 2.51.2