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
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + import '../constants/app_colors.dart'; 4 + import '../models/post.dart'; 5 + import '../providers/multi_feed_provider.dart'; 6 + import '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. 22 + class 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 + 54 + class _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 posts in your feed' 155 + : 'Check back later for new posts', 156 + style: const TextStyle( 157 + fontSize: 14, 158 + color: AppColors.textSecondary, 159 + ), 160 + textAlign: TextAlign.center, 161 + ), 162 + ], 163 + ), 164 + ), 165 + ), 166 + ), 167 + ], 168 + ), 169 + ); 170 + } 171 + 172 + // Posts list 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(), 182 + ), 183 + // Pre-render items 800px above/below viewport for smoother scrolling 184 + cacheExtent: 800, 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 188 + itemCount: 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( 197 + child: Padding( 198 + padding: EdgeInsets.all(16), 199 + child: CircularProgressIndicator(color: AppColors.primary), 200 + ), 201 + ); 202 + } 203 + // Show error message for pagination failures 204 + if (widget.error != null) { 205 + return Container( 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), 212 + ), 213 + child: Column( 214 + children: [ 215 + const Icon( 216 + Icons.error_outline, 217 + color: AppColors.primary, 218 + size: 32, 219 + ), 220 + const SizedBox(height: 8), 221 + Text( 222 + _getUserFriendlyError(widget.error!), 223 + style: const TextStyle( 224 + color: AppColors.textSecondary, 225 + fontSize: 14, 226 + ), 227 + textAlign: TextAlign.center, 228 + ), 229 + const SizedBox(height: 12), 230 + TextButton( 231 + onPressed: widget.onClearErrorAndLoadMore, 232 + style: TextButton.styleFrom( 233 + foregroundColor: AppColors.primary, 234 + ), 235 + child: const Text('Retry'), 236 + ), 237 + ], 238 + ), 239 + ); 240 + } 241 + } 242 + 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( 247 + child: Semantics( 248 + label: 249 + 'Feed post in ${post.post.community.name} by ' 250 + '${post.post.author.displayName ?? post.post.author.handle}. ' 251 + '${post.post.title ?? ""}', 252 + button: true, 253 + child: PostCard(post: post, currentTime: widget.currentTime), 254 + ), 255 + ); 256 + }, 257 + ), 258 + ); 259 + } 260 + 261 + /// Transform technical error messages into user-friendly ones 262 + String _getUserFriendlyError(String error) { 263 + final lowerError = error.toLowerCase(); 264 + 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'; 280 + } 281 + 282 + // Fallback to generic message for unknown errors 283 + return 'Something went wrong. Please try again'; 284 + } 285 + }