···
1
+
import 'package:flutter/material.dart';
3
+
import '../constants/app_colors.dart';
4
+
import '../models/post.dart';
5
+
import '../providers/multi_feed_provider.dart';
6
+
import 'post_card.dart';
8
+
/// FeedPage widget for rendering a single feed's content
10
+
/// Displays a feed with:
11
+
/// - Loading state (spinner when loading initial posts)
12
+
/// - Error state (error message with retry button)
13
+
/// - Empty state (no posts message)
14
+
/// - Posts list (RefreshIndicator + ListView.builder with PostCard widgets)
15
+
/// - Pagination footer (loading indicator or error retry at bottom)
17
+
/// This widget is used within a PageView to render individual feeds
18
+
/// (Discover, For You) in the feed screen.
20
+
/// Uses AutomaticKeepAliveClientMixin to keep the page alive when swiping
21
+
/// between feeds, preventing scroll position jumps during transitions.
22
+
class FeedPage extends StatefulWidget {
24
+
required this.feedType,
25
+
required this.posts,
26
+
required this.isLoading,
27
+
required this.isLoadingMore,
28
+
required this.error,
29
+
required this.scrollController,
30
+
required this.onRefresh,
31
+
required this.onRetry,
32
+
required this.onClearErrorAndLoadMore,
33
+
required this.isAuthenticated,
34
+
required this.currentTime,
38
+
final FeedType feedType;
39
+
final List<FeedViewPost> posts;
40
+
final bool isLoading;
41
+
final bool isLoadingMore;
42
+
final String? error;
43
+
final ScrollController scrollController;
44
+
final Future<void> Function() onRefresh;
45
+
final VoidCallback onRetry;
46
+
final VoidCallback onClearErrorAndLoadMore;
47
+
final bool isAuthenticated;
48
+
final DateTime? currentTime;
51
+
State<FeedPage> createState() => _FeedPageState();
54
+
class _FeedPageState extends State<FeedPage>
55
+
with AutomaticKeepAliveClientMixin {
57
+
bool get wantKeepAlive => true;
60
+
Widget build(BuildContext context) {
61
+
// Required call for AutomaticKeepAliveClientMixin
62
+
super.build(context);
64
+
// Loading state (only show full-screen loader for initial load,
66
+
if (widget.isLoading && widget.posts.isEmpty) {
67
+
return const Center(
68
+
child: CircularProgressIndicator(color: AppColors.primary),
72
+
// Error state (only show full-screen error when no posts loaded
73
+
// yet). If we have posts but pagination failed, we'll show the error
75
+
if (widget.error != null && widget.posts.isEmpty) {
78
+
padding: const EdgeInsets.all(24),
80
+
mainAxisAlignment: MainAxisAlignment.center,
83
+
Icons.error_outline,
85
+
color: AppColors.primary,
87
+
const SizedBox(height: 16),
89
+
'Failed to load feed',
92
+
color: AppColors.textPrimary,
93
+
fontWeight: FontWeight.bold,
96
+
const SizedBox(height: 8),
98
+
_getUserFriendlyError(widget.error!),
99
+
style: const TextStyle(
101
+
color: AppColors.textSecondary,
103
+
textAlign: TextAlign.center,
105
+
const SizedBox(height: 24),
107
+
onPressed: widget.onRetry,
108
+
style: ElevatedButton.styleFrom(
109
+
backgroundColor: AppColors.primary,
111
+
child: const Text('Retry'),
119
+
// Empty state - wrapped in RefreshIndicator so users can pull to refresh
120
+
if (widget.posts.isEmpty) {
121
+
return RefreshIndicator(
122
+
onRefresh: widget.onRefresh,
123
+
color: AppColors.primary,
124
+
child: CustomScrollView(
125
+
physics: const AlwaysScrollableScrollPhysics(),
127
+
SliverFillRemaining(
128
+
hasScrollBody: false,
131
+
padding: const EdgeInsets.all(24),
133
+
mainAxisAlignment: MainAxisAlignment.center,
138
+
color: AppColors.primary,
140
+
const SizedBox(height: 24),
142
+
widget.isAuthenticated
144
+
: 'No posts to discover',
145
+
style: const TextStyle(
147
+
color: AppColors.textPrimary,
148
+
fontWeight: FontWeight.bold,
151
+
const SizedBox(height: 8),
153
+
widget.isAuthenticated
154
+
? 'Subscribe to communities to see posts in your feed'
155
+
: 'Check back later for new posts',
156
+
style: const TextStyle(
158
+
color: AppColors.textSecondary,
160
+
textAlign: TextAlign.center,
173
+
return RefreshIndicator(
174
+
onRefresh: widget.onRefresh,
175
+
color: AppColors.primary,
176
+
child: ListView.builder(
177
+
controller: widget.scrollController,
178
+
// Smooth bouncy scroll physics (iOS-style) with always-scrollable
179
+
// for pull-to-refresh support
180
+
physics: const BouncingScrollPhysics(
181
+
parent: AlwaysScrollableScrollPhysics(),
183
+
// Pre-render items 800px above/below viewport for smoother scrolling
185
+
// Add top padding so content isn't hidden behind transparent header
186
+
padding: const EdgeInsets.only(top: 44),
187
+
// Add extra item for loading indicator or pagination error
189
+
widget.posts.length +
190
+
(widget.isLoadingMore || widget.error != null ? 1 : 0),
191
+
itemBuilder: (context, index) {
192
+
// Footer: loading indicator or error message
193
+
if (index == widget.posts.length) {
194
+
// Show loading indicator for pagination
195
+
if (widget.isLoadingMore) {
196
+
return const Center(
198
+
padding: EdgeInsets.all(16),
199
+
child: CircularProgressIndicator(color: AppColors.primary),
203
+
// Show error message for pagination failures
204
+
if (widget.error != null) {
206
+
margin: const EdgeInsets.all(16),
207
+
padding: const EdgeInsets.all(16),
208
+
decoration: BoxDecoration(
209
+
color: AppColors.background,
210
+
borderRadius: BorderRadius.circular(8),
211
+
border: Border.all(color: AppColors.primary),
216
+
Icons.error_outline,
217
+
color: AppColors.primary,
220
+
const SizedBox(height: 8),
222
+
_getUserFriendlyError(widget.error!),
223
+
style: const TextStyle(
224
+
color: AppColors.textSecondary,
227
+
textAlign: TextAlign.center,
229
+
const SizedBox(height: 12),
231
+
onPressed: widget.onClearErrorAndLoadMore,
232
+
style: TextButton.styleFrom(
233
+
foregroundColor: AppColors.primary,
235
+
child: const Text('Retry'),
243
+
final post = widget.posts[index];
244
+
// RepaintBoundary isolates each post card to prevent unnecessary
245
+
// repaints of other items during scrolling
246
+
return RepaintBoundary(
249
+
'Feed post in ${post.post.community.name} by '
250
+
'${post.post.author.displayName ?? post.post.author.handle}. '
251
+
'${post.post.title ?? ""}',
253
+
child: PostCard(post: post, currentTime: widget.currentTime),
261
+
/// Transform technical error messages into user-friendly ones
262
+
String _getUserFriendlyError(String error) {
263
+
final lowerError = error.toLowerCase();
265
+
if (lowerError.contains('socketexception') ||
266
+
lowerError.contains('network') ||
267
+
lowerError.contains('connection refused')) {
268
+
return 'Please check your internet connection';
269
+
} else if (lowerError.contains('timeoutexception') ||
270
+
lowerError.contains('timeout')) {
271
+
return 'Request timed out. Please try again';
272
+
} else if (lowerError.contains('401') ||
273
+
lowerError.contains('unauthorized')) {
274
+
return 'Authentication failed. Please sign in again';
275
+
} else if (lowerError.contains('404') || lowerError.contains('not found')) {
276
+
return 'Content not found';
277
+
} else if (lowerError.contains('500') ||
278
+
lowerError.contains('internal server')) {
279
+
return 'Server error. Please try again later';
282
+
// Fallback to generic message for unknown errors
283
+
return 'Something went wrong. Please try again';