Main coves client
1import 'package:flutter/material.dart';
2
3import '../constants/app_colors.dart';
4import '../models/post.dart';
5import '../providers/multi_feed_provider.dart';
6import 'post_card.dart';
7
8/// FeedPage widget for rendering a single feed's content
9///
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)
16///
17/// This widget is used within a PageView to render individual feeds
18/// (Discover, For You) in the feed screen.
19///
20/// Uses AutomaticKeepAliveClientMixin to keep the page alive when swiping
21/// between feeds, preventing scroll position jumps during transitions.
22class FeedPage extends StatefulWidget {
23 const FeedPage({
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,
35 super.key,
36 });
37
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;
49
50 @override
51 State<FeedPage> createState() => _FeedPageState();
52}
53
54class _FeedPageState extends State<FeedPage>
55 with AutomaticKeepAliveClientMixin {
56 @override
57 bool get wantKeepAlive => true;
58
59 @override
60 Widget build(BuildContext context) {
61 // Required call for AutomaticKeepAliveClientMixin
62 super.build(context);
63
64 // Loading state (only show full-screen loader for initial load,
65 // not refresh)
66 if (widget.isLoading && widget.posts.isEmpty) {
67 return const Center(
68 child: CircularProgressIndicator(color: AppColors.primary),
69 );
70 }
71
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
74 // at the bottom
75 if (widget.error != null && widget.posts.isEmpty) {
76 return Center(
77 child: Padding(
78 padding: const EdgeInsets.all(24),
79 child: Column(
80 mainAxisAlignment: MainAxisAlignment.center,
81 children: [
82 const Icon(
83 Icons.error_outline,
84 size: 64,
85 color: AppColors.primary,
86 ),
87 const SizedBox(height: 16),
88 const Text(
89 'Failed to load feed',
90 style: TextStyle(
91 fontSize: 20,
92 color: AppColors.textPrimary,
93 fontWeight: FontWeight.bold,
94 ),
95 ),
96 const SizedBox(height: 8),
97 Text(
98 _getUserFriendlyError(widget.error!),
99 style: const TextStyle(
100 fontSize: 14,
101 color: AppColors.textSecondary,
102 ),
103 textAlign: TextAlign.center,
104 ),
105 const SizedBox(height: 24),
106 ElevatedButton(
107 onPressed: widget.onRetry,
108 style: ElevatedButton.styleFrom(
109 backgroundColor: AppColors.primary,
110 ),
111 child: const Text('Retry'),
112 ),
113 ],
114 ),
115 ),
116 );
117 }
118
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(),
126 slivers: [
127 SliverFillRemaining(
128 hasScrollBody: false,
129 child: Center(
130 child: Padding(
131 padding: const EdgeInsets.all(24),
132 child: Column(
133 mainAxisAlignment: MainAxisAlignment.center,
134 children: [
135 const Icon(
136 Icons.forum,
137 size: 64,
138 color: AppColors.primary,
139 ),
140 const SizedBox(height: 24),
141 Text(
142 widget.isAuthenticated
143 ? 'No posts yet'
144 : 'No posts to discover',
145 style: const TextStyle(
146 fontSize: 20,
147 color: AppColors.textPrimary,
148 fontWeight: FontWeight.bold,
149 ),
150 ),
151 const SizedBox(height: 8),
152 Text(
153 widget.isAuthenticated
154 ? 'Subscribe to communities to see '
155 'posts in your feed'
156 : 'Check back later for new posts',
157 style: const TextStyle(
158 fontSize: 14,
159 color: AppColors.textSecondary,
160 ),
161 textAlign: TextAlign.center,
162 ),
163 ],
164 ),
165 ),
166 ),
167 ),
168 ],
169 ),
170 );
171 }
172
173 // Posts list
174 return RefreshIndicator(
175 onRefresh: widget.onRefresh,
176 color: AppColors.primary,
177 child: ListView.builder(
178 controller: widget.scrollController,
179 // Smooth bouncy scroll physics (iOS-style) with always-scrollable
180 // for pull-to-refresh support
181 physics: const BouncingScrollPhysics(
182 parent: AlwaysScrollableScrollPhysics(),
183 ),
184 // Pre-render items 800px above/below viewport for smoother scrolling
185 cacheExtent: 800,
186 // Add top padding so content isn't hidden behind transparent header
187 padding: const EdgeInsets.only(top: 44),
188 // Add extra item for loading indicator or pagination error
189 itemCount:
190 widget.posts.length +
191 (widget.isLoadingMore || widget.error != null ? 1 : 0),
192 itemBuilder: (context, index) {
193 // Footer: loading indicator or error message
194 if (index == widget.posts.length) {
195 // Show loading indicator for pagination
196 if (widget.isLoadingMore) {
197 return const Center(
198 child: Padding(
199 padding: EdgeInsets.all(16),
200 child: CircularProgressIndicator(color: AppColors.primary),
201 ),
202 );
203 }
204 // Show error message for pagination failures
205 if (widget.error != null) {
206 return Container(
207 margin: const EdgeInsets.all(16),
208 padding: const EdgeInsets.all(16),
209 decoration: BoxDecoration(
210 color: AppColors.background,
211 borderRadius: BorderRadius.circular(8),
212 border: Border.all(color: AppColors.primary),
213 ),
214 child: Column(
215 children: [
216 const Icon(
217 Icons.error_outline,
218 color: AppColors.primary,
219 size: 32,
220 ),
221 const SizedBox(height: 8),
222 Text(
223 _getUserFriendlyError(widget.error!),
224 style: const TextStyle(
225 color: AppColors.textSecondary,
226 fontSize: 14,
227 ),
228 textAlign: TextAlign.center,
229 ),
230 const SizedBox(height: 12),
231 TextButton(
232 onPressed: widget.onClearErrorAndLoadMore,
233 style: TextButton.styleFrom(
234 foregroundColor: AppColors.primary,
235 ),
236 child: const Text('Retry'),
237 ),
238 ],
239 ),
240 );
241 }
242 }
243
244 final post = widget.posts[index];
245 // RepaintBoundary isolates each post card to prevent unnecessary
246 // repaints of other items during scrolling
247 return RepaintBoundary(
248 child: Semantics(
249 label:
250 'Feed post in ${post.post.community.name} by '
251 '${post.post.author.displayName ?? post.post.author.handle}. '
252 '${post.post.title ?? ""}',
253 button: true,
254 child: PostCard(post: post, currentTime: widget.currentTime),
255 ),
256 );
257 },
258 ),
259 );
260 }
261
262 /// Transform technical error messages into user-friendly ones
263 String _getUserFriendlyError(String error) {
264 final lowerError = error.toLowerCase();
265
266 if (lowerError.contains('socketexception') ||
267 lowerError.contains('network') ||
268 lowerError.contains('connection refused')) {
269 return 'Please check your internet connection';
270 } else if (lowerError.contains('timeoutexception') ||
271 lowerError.contains('timeout')) {
272 return 'Request timed out. Please try again';
273 } else if (lowerError.contains('401') ||
274 lowerError.contains('unauthorized')) {
275 return 'Authentication failed. Please sign in again';
276 } else if (lowerError.contains('404') || lowerError.contains('not found')) {
277 return 'Content not found';
278 } else if (lowerError.contains('500') ||
279 lowerError.contains('internal server')) {
280 return 'Server error. Please try again later';
281 }
282
283 // Fallback to generic message for unknown errors
284 return 'Something went wrong. Please try again';
285 }
286}