feat(feed): extract FeedPage widget with refreshable empty state

Extracts feed rendering logic into a reusable FeedPage widget that
handles all feed states:

- Loading: Centered CircularProgressIndicator
- Error: User-friendly message with Retry button
- Empty: Contextual message based on auth state
- Posts: ListView.builder with pagination support

Key improvements:
- Empty state now wrapped in RefreshIndicator with CustomScrollView
and SliverFillRemaining, allowing pull-to-refresh when feed is empty
- Error messages are user-friendly (maps technical errors to readable text)
- Loading more indicator at list bottom during pagination
- Proper scroll controller integration for infinite scroll

This fixes an issue where unauthenticated users with an empty Discover
feed had no way to retry without restarting the app.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

Changed files
+285
lib
widgets
+285
lib/widgets/feed_page.dart
···
+
import 'package:flutter/material.dart';
+
+
import '../constants/app_colors.dart';
+
import '../models/post.dart';
+
import '../providers/multi_feed_provider.dart';
+
import 'post_card.dart';
+
+
/// FeedPage widget for rendering a single feed's content
+
///
+
/// Displays a feed with:
+
/// - Loading state (spinner when loading initial posts)
+
/// - Error state (error message with retry button)
+
/// - Empty state (no posts message)
+
/// - Posts list (RefreshIndicator + ListView.builder with PostCard widgets)
+
/// - Pagination footer (loading indicator or error retry at bottom)
+
///
+
/// This widget is used within a PageView to render individual feeds
+
/// (Discover, For You) in the feed screen.
+
///
+
/// Uses AutomaticKeepAliveClientMixin to keep the page alive when swiping
+
/// between feeds, preventing scroll position jumps during transitions.
+
class FeedPage extends StatefulWidget {
+
const FeedPage({
+
required this.feedType,
+
required this.posts,
+
required this.isLoading,
+
required this.isLoadingMore,
+
required this.error,
+
required this.scrollController,
+
required this.onRefresh,
+
required this.onRetry,
+
required this.onClearErrorAndLoadMore,
+
required this.isAuthenticated,
+
required this.currentTime,
+
super.key,
+
});
+
+
final FeedType feedType;
+
final List<FeedViewPost> posts;
+
final bool isLoading;
+
final bool isLoadingMore;
+
final String? error;
+
final ScrollController scrollController;
+
final Future<void> Function() onRefresh;
+
final VoidCallback onRetry;
+
final VoidCallback onClearErrorAndLoadMore;
+
final bool isAuthenticated;
+
final DateTime? currentTime;
+
+
@override
+
State<FeedPage> createState() => _FeedPageState();
+
}
+
+
class _FeedPageState extends State<FeedPage>
+
with AutomaticKeepAliveClientMixin {
+
@override
+
bool get wantKeepAlive => true;
+
+
@override
+
Widget build(BuildContext context) {
+
// Required call for AutomaticKeepAliveClientMixin
+
super.build(context);
+
+
// Loading state (only show full-screen loader for initial load,
+
// not refresh)
+
if (widget.isLoading && widget.posts.isEmpty) {
+
return const Center(
+
child: CircularProgressIndicator(color: AppColors.primary),
+
);
+
}
+
+
// Error state (only show full-screen error when no posts loaded
+
// yet). If we have posts but pagination failed, we'll show the error
+
// at the bottom
+
if (widget.error != null && widget.posts.isEmpty) {
+
return Center(
+
child: Padding(
+
padding: const EdgeInsets.all(24),
+
child: Column(
+
mainAxisAlignment: MainAxisAlignment.center,
+
children: [
+
const Icon(
+
Icons.error_outline,
+
size: 64,
+
color: AppColors.primary,
+
),
+
const SizedBox(height: 16),
+
const Text(
+
'Failed to load feed',
+
style: TextStyle(
+
fontSize: 20,
+
color: AppColors.textPrimary,
+
fontWeight: FontWeight.bold,
+
),
+
),
+
const SizedBox(height: 8),
+
Text(
+
_getUserFriendlyError(widget.error!),
+
style: const TextStyle(
+
fontSize: 14,
+
color: AppColors.textSecondary,
+
),
+
textAlign: TextAlign.center,
+
),
+
const SizedBox(height: 24),
+
ElevatedButton(
+
onPressed: widget.onRetry,
+
style: ElevatedButton.styleFrom(
+
backgroundColor: AppColors.primary,
+
),
+
child: const Text('Retry'),
+
),
+
],
+
),
+
),
+
);
+
}
+
+
// Empty state - wrapped in RefreshIndicator so users can pull to refresh
+
if (widget.posts.isEmpty) {
+
return RefreshIndicator(
+
onRefresh: widget.onRefresh,
+
color: AppColors.primary,
+
child: CustomScrollView(
+
physics: const AlwaysScrollableScrollPhysics(),
+
slivers: [
+
SliverFillRemaining(
+
hasScrollBody: false,
+
child: Center(
+
child: Padding(
+
padding: const EdgeInsets.all(24),
+
child: Column(
+
mainAxisAlignment: MainAxisAlignment.center,
+
children: [
+
const Icon(
+
Icons.forum,
+
size: 64,
+
color: AppColors.primary,
+
),
+
const SizedBox(height: 24),
+
Text(
+
widget.isAuthenticated
+
? 'No posts yet'
+
: 'No posts to discover',
+
style: const TextStyle(
+
fontSize: 20,
+
color: AppColors.textPrimary,
+
fontWeight: FontWeight.bold,
+
),
+
),
+
const SizedBox(height: 8),
+
Text(
+
widget.isAuthenticated
+
? 'Subscribe to communities to see posts in your feed'
+
: 'Check back later for new posts',
+
style: const TextStyle(
+
fontSize: 14,
+
color: AppColors.textSecondary,
+
),
+
textAlign: TextAlign.center,
+
),
+
],
+
),
+
),
+
),
+
),
+
],
+
),
+
);
+
}
+
+
// Posts list
+
return RefreshIndicator(
+
onRefresh: widget.onRefresh,
+
color: AppColors.primary,
+
child: ListView.builder(
+
controller: widget.scrollController,
+
// Smooth bouncy scroll physics (iOS-style) with always-scrollable
+
// for pull-to-refresh support
+
physics: const BouncingScrollPhysics(
+
parent: AlwaysScrollableScrollPhysics(),
+
),
+
// Pre-render items 800px above/below viewport for smoother scrolling
+
cacheExtent: 800,
+
// Add top padding so content isn't hidden behind transparent header
+
padding: const EdgeInsets.only(top: 44),
+
// Add extra item for loading indicator or pagination error
+
itemCount:
+
widget.posts.length +
+
(widget.isLoadingMore || widget.error != null ? 1 : 0),
+
itemBuilder: (context, index) {
+
// Footer: loading indicator or error message
+
if (index == widget.posts.length) {
+
// Show loading indicator for pagination
+
if (widget.isLoadingMore) {
+
return const Center(
+
child: Padding(
+
padding: EdgeInsets.all(16),
+
child: CircularProgressIndicator(color: AppColors.primary),
+
),
+
);
+
}
+
// Show error message for pagination failures
+
if (widget.error != null) {
+
return Container(
+
margin: const EdgeInsets.all(16),
+
padding: const EdgeInsets.all(16),
+
decoration: BoxDecoration(
+
color: AppColors.background,
+
borderRadius: BorderRadius.circular(8),
+
border: Border.all(color: AppColors.primary),
+
),
+
child: Column(
+
children: [
+
const Icon(
+
Icons.error_outline,
+
color: AppColors.primary,
+
size: 32,
+
),
+
const SizedBox(height: 8),
+
Text(
+
_getUserFriendlyError(widget.error!),
+
style: const TextStyle(
+
color: AppColors.textSecondary,
+
fontSize: 14,
+
),
+
textAlign: TextAlign.center,
+
),
+
const SizedBox(height: 12),
+
TextButton(
+
onPressed: widget.onClearErrorAndLoadMore,
+
style: TextButton.styleFrom(
+
foregroundColor: AppColors.primary,
+
),
+
child: const Text('Retry'),
+
),
+
],
+
),
+
);
+
}
+
}
+
+
final post = widget.posts[index];
+
// RepaintBoundary isolates each post card to prevent unnecessary
+
// repaints of other items during scrolling
+
return RepaintBoundary(
+
child: Semantics(
+
label:
+
'Feed post in ${post.post.community.name} by '
+
'${post.post.author.displayName ?? post.post.author.handle}. '
+
'${post.post.title ?? ""}',
+
button: true,
+
child: PostCard(post: post, currentTime: widget.currentTime),
+
),
+
);
+
},
+
),
+
);
+
}
+
+
/// Transform technical error messages into user-friendly ones
+
String _getUserFriendlyError(String error) {
+
final lowerError = error.toLowerCase();
+
+
if (lowerError.contains('socketexception') ||
+
lowerError.contains('network') ||
+
lowerError.contains('connection refused')) {
+
return 'Please check your internet connection';
+
} else if (lowerError.contains('timeoutexception') ||
+
lowerError.contains('timeout')) {
+
return 'Request timed out. Please try again';
+
} else if (lowerError.contains('401') ||
+
lowerError.contains('unauthorized')) {
+
return 'Authentication failed. Please sign in again';
+
} else if (lowerError.contains('404') || lowerError.contains('not found')) {
+
return 'Content not found';
+
} else if (lowerError.contains('500') ||
+
lowerError.contains('internal server')) {
+
return 'Server error. Please try again later';
+
}
+
+
// Fallback to generic message for unknown errors
+
return 'Something went wrong. Please try again';
+
}
+
}