feat(feed): add TikTok-style feed type selector and communities tab

- 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 <noreply@anthropic.com>

+42 -5
lib/providers/feed_provider.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.
···
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);
···
// Feed configuration
String _sort = 'hot';
String? _timeframe;
+
FeedType _feedType = FeedType.discover;
// Time update mechanism for periodic UI refreshes
Timer? _timeUpdateTimer;
···
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
///
···
}
}
-
/// 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<void> 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);
···
if (_posts.isNotEmpty && _timeUpdateTimer == null) {
startTimeUpdates();
}
+
}
+
+
/// Switch feed type and reload
+
Future<void> 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
+162 -14
lib/screens/home/feed_screen.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<FeedScreen> createState() => _FeedScreenState();
···
);
final isLoading = context.select<FeedProvider, bool>((p) => p.isLoading);
final error = context.select<FeedProvider, String?>((p) => p.error);
+
final feedType = context.select<FeedProvider, FeedType>(
+
(p) => p.feedType,
+
);
// IMPORTANT: This relies on FeedProvider creating new list instances
// (_posts = [..._posts, ...response.feed]) rather than mutating in-place.
···
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<FeedProvider>(context, listen: false).setFeedType(type);
+
}
+
Widget _buildBody({
required bool isLoading,
required String? error,
···
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) {
+24 -13
lib/screens/home/main_shell_screen.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});
···
class _MainShellScreenState extends State<MainShellScreen> {
int _selectedIndex = 0;
-
-
static const List<Widget> _screens = [
-
FeedScreen(),
-
SearchScreen(),
-
CreatePostScreen(),
-
NotificationsScreen(),
-
ProfileScreen(),
-
];
void _onItemTapped(int index) {
setState(() {
···
});
}
+
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),
···
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'),
···
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);
+12 -8
lib/screens/home/search_screen.dart lib/screens/home/communities_screen.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(
···
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,
···
),
SizedBox(height: 16),
Text(
-
'Search communities and conversations',
+
'Discover and join communities',
style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)),
textAlign: TextAlign.center,
),