chore: fix all analyzer issues and upgrade dependencies

Resolved all 88 analyzer issues and modernized the codebase with dependency
updates and Android build configuration improvements.

## Code Quality Fixes (88 → 0 issues)
- Replace deprecated withOpacity() with withValues() for Color objects
- Add specific exception types to catch clauses (on Exception)
- Fix unawaited future in feed provider tests
- Add missing type annotation to OAuth service
- Fix line length violations (80 char limit)
- Fix control body formatting (multi-line statements)
- Convert positional bool params to named params in tests
- Apply automated dart fix suggestions

## Dependency Management
- Remove unused bluesky package and 18 transitive dependencies
- Remove all discontinued packages (at_identifier, at_uri, nsid)
- Upgrade flutter_lints: 5.0.0 → 6.0.0
- Upgrade 19 compatible transitive dependencies
- Update Flutter SDK: 3.29.2 → 3.35.7
- Update Dart SDK: 3.7.2 → 3.9.2

## Android Build Configuration
- Upgrade Kotlin: 1.8.22 → 2.1.0 (required by Flutter)
- Force Java 11 compatibility across all plugins
- Set Kotlin JVM target to 11 for consistency
- Eliminate all build warnings

Result: Clean analyzer, no build warnings, modern dependencies

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

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

+18
android/build.gradle.kts
··· 11 subprojects { 12 val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) 13 project.layout.buildDirectory.value(newSubprojectBuildDir) 14 } 15 subprojects { 16 project.evaluationDependsOn(":app") 17 }
··· 11 subprojects { 12 val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) 13 project.layout.buildDirectory.value(newSubprojectBuildDir) 14 + 15 + afterEvaluate { 16 + if (project.hasProperty("android")) { 17 + project.extensions.configure<com.android.build.gradle.BaseExtension> { 18 + compileOptions { 19 + sourceCompatibility = JavaVersion.VERSION_11 20 + targetCompatibility = JavaVersion.VERSION_11 21 + } 22 + } 23 + } 24 + 25 + tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { 26 + kotlinOptions { 27 + jvmTarget = "11" 28 + } 29 + } 30 + } 31 } 32 + 33 subprojects { 34 project.evaluationDependsOn(":app") 35 }
+1 -1
android/settings.gradle.kts
··· 19 plugins { 20 id("dev.flutter.flutter-plugin-loader") version "1.0.0" 21 id("com.android.application") version "8.7.0" apply false 22 - id("org.jetbrains.kotlin.android") version "1.8.22" apply false 23 } 24 25 include(":app")
··· 19 plugins { 20 id("dev.flutter.flutter-plugin-loader") version "1.0.0" 21 id("com.android.application") version "8.7.0" apply false 22 + id("org.jetbrains.kotlin.android") version "2.1.0" apply false 23 } 24 25 include(":app")
+14 -8
lib/config/oauth_config.dart
··· 2 3 /// OAuth Configuration for atProto 4 /// 5 - /// This configuration provides ClientMetadata for the new atproto_oauth_flutter package. 6 - /// The new package handles proper decentralized OAuth discovery (works with ANY PDS). 7 class OAuthConfig { 8 // OAuth Server Configuration 9 - // Cloudflare Worker that hosts client-metadata.json and handles OAuth callbacks 10 static const String oauthServerUrl = 11 'https://lingering-darkness-50a6.brettmay0212.workers.dev'; 12 ··· 24 // Derived OAuth URLs 25 static const String clientId = '$oauthServerUrl/client-metadata.json'; 26 27 - // IMPORTANT: Private-use URI schemes (RFC 8252) require SINGLE slash, not double! 28 // Correct: dev.workers.example:/oauth/callback 29 // Incorrect: dev.workers.example://oauth/callback 30 - static const String customSchemeCallback = '$customScheme:/oauth/callback'; 31 32 - // HTTPS callback (fallback for PDS that don't support custom URI schemes) 33 static const String httpsCallback = '$oauthServerUrl/oauth/callback'; 34 35 // OAuth Scopes - recommended scope for atProto ··· 49 static ClientMetadata createClientMetadata() { 50 return const ClientMetadata( 51 clientId: clientId, 52 - // Use HTTPS as PRIMARY - prevents browser re-navigation that invalidates auth codes 53 - // Custom scheme as fallback (Worker page redirects to custom scheme anyway) 54 redirectUris: [httpsCallback, customSchemeCallback], 55 scope: scope, 56 clientName: clientName,
··· 2 3 /// OAuth Configuration for atProto 4 /// 5 + /// This configuration provides ClientMetadata for the new 6 + /// atproto_oauth_flutter package. The new package handles proper 7 + /// decentralized OAuth discovery (works with ANY PDS). 8 class OAuthConfig { 9 // OAuth Server Configuration 10 + // Cloudflare Worker that hosts client-metadata.json and handles OAuth 11 + // callbacks 12 static const String oauthServerUrl = 13 'https://lingering-darkness-50a6.brettmay0212.workers.dev'; 14 ··· 26 // Derived OAuth URLs 27 static const String clientId = '$oauthServerUrl/client-metadata.json'; 28 29 + // IMPORTANT: Private-use URI schemes (RFC 8252) require SINGLE slash, 30 + // not double! 31 // Correct: dev.workers.example:/oauth/callback 32 // Incorrect: dev.workers.example://oauth/callback 33 + static const String customSchemeCallback = 34 + '$customScheme:/oauth/callback'; 35 36 + // HTTPS callback (fallback for PDS that don't support custom 37 + // URI schemes) 38 static const String httpsCallback = '$oauthServerUrl/oauth/callback'; 39 40 // OAuth Scopes - recommended scope for atProto ··· 54 static ClientMetadata createClientMetadata() { 55 return const ClientMetadata( 56 clientId: clientId, 57 + // Use HTTPS as PRIMARY - prevents browser re-navigation that 58 + // invalidates auth codes. Custom scheme as fallback (Worker page 59 + // redirects to custom scheme anyway) 60 redirectUris: [httpsCallback, customSchemeCallback], 61 scope: scope, 62 clientName: clientName,
+2 -1
lib/main.dart
··· 98 if (state.uri.scheme == OAuthConfig.customScheme) { 99 if (kDebugMode) { 100 print( 101 - '⚠️ OAuth callback in errorBuilder - flutter_web_auth_2 should handle it', 102 ); 103 print(' URI: ${state.uri}'); 104 }
··· 98 if (state.uri.scheme == OAuthConfig.customScheme) { 99 if (kDebugMode) { 100 print( 101 + '⚠️ OAuth callback in errorBuilder - ' 102 + 'flutter_web_auth_2 should handle it', 103 ); 104 print(' URI: ${state.uri}'); 105 }
+9 -6
lib/providers/auth_provider.dart
··· 15 /// ✅ Tokens are stored securely by the package (iOS Keychain / Android EncryptedSharedPreferences) 16 /// ✅ Automatic token refresh handled by the package 17 class AuthProvider with ChangeNotifier { 18 - final OAuthService _oauthService; 19 20 /// Constructor with optional OAuthService for dependency injection (testing) 21 AuthProvider({OAuthService? oauthService}) 22 : _oauthService = oauthService ?? OAuthService(); 23 24 // SharedPreferences keys for storing session info 25 // The DID and handle are public information, so SharedPreferences is fine ··· 51 /// The token is automatically refreshed if expired. 52 /// If token refresh fails (e.g., revoked server-side), signs out the user. 53 Future<String?> getAccessToken() async { 54 - if (_session == null) return null; 55 56 try { 57 // Access the session getter to get the token set 58 final session = await _session!.sessionGetter.get(_session!.sub); 59 return session.tokenSet.accessToken; 60 - } catch (e) { 61 if (kDebugMode) { 62 print('❌ Failed to get access token: $e'); 63 print('🔄 Token refresh failed - signing out user'); ··· 124 print('No stored DID found - user not logged in'); 125 } 126 } 127 - } catch (e) { 128 _error = e.toString(); 129 if (kDebugMode) { 130 print('❌ Failed to initialize auth: $e'); ··· 168 _did = session.sub; 169 _handle = trimmedHandle; 170 171 - // Store the DID and handle in SharedPreferences so we can restore on next launch 172 final prefs = await SharedPreferences.getInstance(); 173 await prefs.setString(_prefKeyDid, session.sub); 174 await prefs.setString(_prefKeyHandle, trimmedHandle); ··· 232 if (kDebugMode) { 233 print('✅ Successfully signed out'); 234 } 235 - } catch (e) { 236 _error = e.toString(); 237 if (kDebugMode) { 238 print('⚠️ Sign out failed: $e');
··· 15 /// ✅ Tokens are stored securely by the package (iOS Keychain / Android EncryptedSharedPreferences) 16 /// ✅ Automatic token refresh handled by the package 17 class AuthProvider with ChangeNotifier { 18 19 /// Constructor with optional OAuthService for dependency injection (testing) 20 AuthProvider({OAuthService? oauthService}) 21 : _oauthService = oauthService ?? OAuthService(); 22 + final OAuthService _oauthService; 23 24 // SharedPreferences keys for storing session info 25 // The DID and handle are public information, so SharedPreferences is fine ··· 51 /// The token is automatically refreshed if expired. 52 /// If token refresh fails (e.g., revoked server-side), signs out the user. 53 Future<String?> getAccessToken() async { 54 + if (_session == null) { 55 + return null; 56 + } 57 58 try { 59 // Access the session getter to get the token set 60 final session = await _session!.sessionGetter.get(_session!.sub); 61 return session.tokenSet.accessToken; 62 + } on Exception catch (e) { 63 if (kDebugMode) { 64 print('❌ Failed to get access token: $e'); 65 print('🔄 Token refresh failed - signing out user'); ··· 126 print('No stored DID found - user not logged in'); 127 } 128 } 129 + } on Exception catch (e) { 130 _error = e.toString(); 131 if (kDebugMode) { 132 print('❌ Failed to initialize auth: $e'); ··· 170 _did = session.sub; 171 _handle = trimmedHandle; 172 173 + // Store the DID and handle in SharedPreferences so we can restore 174 + // on next launch 175 final prefs = await SharedPreferences.getInstance(); 176 await prefs.setString(_prefKeyDid, session.sub); 177 await prefs.setString(_prefKeyHandle, trimmedHandle); ··· 235 if (kDebugMode) { 236 print('✅ Successfully signed out'); 237 } 238 + } on Exception catch (e) { 239 _error = e.toString(); 240 if (kDebugMode) { 241 print('⚠️ Sign out failed: $e');
+29 -17
lib/providers/feed_provider.dart
··· 8 /// Manages feed state and fetching logic. 9 /// Supports both authenticated timeline and public discover feed. 10 /// 11 - /// IMPORTANT: Accepts AuthProvider reference to fetch fresh access tokens 12 - /// before each authenticated request (critical for atProto OAuth token rotation). 13 class FeedProvider with ChangeNotifier { 14 15 FeedProvider(this._authProvider, {CovesApiService? apiService}) { ··· 19 CovesApiService(tokenGetter: _authProvider.getAccessToken); 20 21 // [P0 FIX] Listen to auth state changes and clear feed on sign-out 22 - // This prevents privacy bug where logged-out users see their private timeline 23 - // until they manually refresh. 24 _authProvider.addListener(_onAuthChanged); 25 } 26 27 /// Handle authentication state changes 28 /// 29 - /// When the user signs out (isAuthenticated becomes false), immediately 30 - /// clear the feed to prevent showing personalized content to logged-out users. 31 - /// This fixes a privacy bug where token refresh failures would sign out the user 32 - /// but leave their private timeline visible until manual refresh. 33 void _onAuthChanged() { 34 if (!_authProvider.isAuthenticated && _posts.isNotEmpty) { 35 if (kDebugMode) { 36 - debugPrint('🔒 Auth state changed to unauthenticated - clearing feed'); 37 } 38 reset(); 39 // Automatically load the public discover feed ··· 64 String get sort => _sort; 65 String? get timeframe => _timeframe; 66 67 - /// Load feed based on authentication state (business logic encapsulation) 68 /// 69 - /// This method encapsulates the business logic of deciding which feed to fetch. 70 - /// Previously this logic was in the UI layer (FeedScreen), violating clean architecture. 71 Future<void> loadFeed({bool refresh = false}) async { 72 if (_authProvider.isAuthenticated) { 73 await fetchTimeline(refresh: refresh); ··· 76 } 77 } 78 79 - /// Common feed fetching logic (DRY principle - eliminates code duplication) 80 Future<void> _fetchFeed({ 81 required bool refresh, 82 required Future<TimelineResponse> Function() fetcher, 83 required String feedName, 84 }) async { 85 - if (_isLoading || _isLoadingMore) return; 86 87 try { 88 if (refresh) { 89 _isLoading = true; 90 // DON'T clear _posts, _cursor, or _hasMore yet 91 // Keep existing data visible until refresh succeeds 92 - // This prevents transient failures from wiping the user's feed and pagination state 93 _error = null; 94 } else { 95 _isLoadingMore = true; ··· 114 if (kDebugMode) { 115 debugPrint('✅ $feedName loaded: ${_posts.length} posts total'); 116 } 117 - } catch (e) { 118 _error = e.toString(); 119 if (kDebugMode) { 120 debugPrint('❌ Failed to fetch $feedName: $e'); ··· 158 159 /// Load more posts (pagination) 160 Future<void> loadMore() async { 161 - if (!_hasMore || _isLoadingMore) return; 162 await loadFeed(); 163 } 164
··· 8 /// Manages feed state and fetching logic. 9 /// Supports both authenticated timeline and public discover feed. 10 /// 11 + /// IMPORTANT: Accepts AuthProvider reference to fetch fresh access 12 + /// tokens before each authenticated request (critical for atProto OAuth 13 + /// token rotation). 14 class FeedProvider with ChangeNotifier { 15 16 FeedProvider(this._authProvider, {CovesApiService? apiService}) { ··· 20 CovesApiService(tokenGetter: _authProvider.getAccessToken); 21 22 // [P0 FIX] Listen to auth state changes and clear feed on sign-out 23 + // This prevents privacy bug where logged-out users see their private 24 + // timeline until they manually refresh. 25 _authProvider.addListener(_onAuthChanged); 26 } 27 28 /// Handle authentication state changes 29 /// 30 + /// When the user signs out (isAuthenticated becomes false), 31 + /// immediately clear the feed to prevent showing personalized content 32 + /// to logged-out users. This fixes a privacy bug where token refresh 33 + /// failures would sign out the user but leave their private timeline 34 + /// visible until manual refresh. 35 void _onAuthChanged() { 36 if (!_authProvider.isAuthenticated && _posts.isNotEmpty) { 37 if (kDebugMode) { 38 + debugPrint( 39 + '🔒 Auth state changed to unauthenticated - clearing feed', 40 + ); 41 } 42 reset(); 43 // Automatically load the public discover feed ··· 68 String get sort => _sort; 69 String? get timeframe => _timeframe; 70 71 + /// Load feed based on authentication state (business logic 72 + /// encapsulation) 73 /// 74 + /// This method encapsulates the business logic of deciding which feed 75 + /// to fetch. Previously this logic was in the UI layer (FeedScreen), 76 + /// violating clean architecture. 77 Future<void> loadFeed({bool refresh = false}) async { 78 if (_authProvider.isAuthenticated) { 79 await fetchTimeline(refresh: refresh); ··· 82 } 83 } 84 85 + /// Common feed fetching logic (DRY principle - eliminates code 86 + /// duplication) 87 Future<void> _fetchFeed({ 88 required bool refresh, 89 required Future<TimelineResponse> Function() fetcher, 90 required String feedName, 91 }) async { 92 + if (_isLoading || _isLoadingMore) { 93 + return; 94 + } 95 96 try { 97 if (refresh) { 98 _isLoading = true; 99 // DON'T clear _posts, _cursor, or _hasMore yet 100 // Keep existing data visible until refresh succeeds 101 + // This prevents transient failures from wiping the user's feed 102 + // and pagination state 103 _error = null; 104 } else { 105 _isLoadingMore = true; ··· 124 if (kDebugMode) { 125 debugPrint('✅ $feedName loaded: ${_posts.length} posts total'); 126 } 127 + } on Exception catch (e) { 128 _error = e.toString(); 129 if (kDebugMode) { 130 debugPrint('❌ Failed to fetch $feedName: $e'); ··· 168 169 /// Load more posts (pagination) 170 Future<void> loadMore() async { 171 + if (!_hasMore || _isLoadingMore) { 172 + return; 173 + } 174 await loadFeed(); 175 } 176
+10 -8
lib/screens/auth/login_screen.dart
··· 38 // Navigate to feed on successful login 39 context.go('/feed'); 40 } 41 - } catch (e) { 42 if (mounted) { 43 ScaffoldMessenger.of(context).showSnackBar( 44 SnackBar( ··· 147 } 148 // Basic handle validation 149 if (!value.contains('.')) { 150 - return 'Handle must contain a domain (e.g., user.bsky.social)'; 151 } 152 return null; 153 }, ··· 166 167 // Info text 168 const Text( 169 - 'You\'ll be redirected to authorize this app with your atProto provider.', 170 style: TextStyle(fontSize: 14, color: Color(0xFF5A6B7F)), 171 textAlign: TextAlign.center, 172 ), ··· 179 onPressed: () { 180 showDialog( 181 context: context, 182 - builder: 183 - (context) => AlertDialog( 184 backgroundColor: const Color(0xFF1A2028), 185 title: const Text( 186 'What is a handle?', 187 style: TextStyle(color: Colors.white), 188 ), 189 content: const Text( 190 - 'Your handle is your unique identifier on the atProto network, ' 191 - 'like alice.bsky.social. If you don\'t have one yet, you can create ' 192 - 'an account at bsky.app.', 193 style: TextStyle(color: Color(0xFFB6C2D2)), 194 ), 195 actions: [
··· 38 // Navigate to feed on successful login 39 context.go('/feed'); 40 } 41 + } on Exception catch (e) { 42 if (mounted) { 43 ScaffoldMessenger.of(context).showSnackBar( 44 SnackBar( ··· 147 } 148 // Basic handle validation 149 if (!value.contains('.')) { 150 + return 'Handle must contain a domain ' 151 + '(e.g., user.bsky.social)'; 152 } 153 return null; 154 }, ··· 167 168 // Info text 169 const Text( 170 + 'You\'ll be redirected to authorize this app with your ' 171 + 'atProto provider.', 172 style: TextStyle(fontSize: 14, color: Color(0xFF5A6B7F)), 173 textAlign: TextAlign.center, 174 ), ··· 181 onPressed: () { 182 showDialog( 183 context: context, 184 + builder: (context) => AlertDialog( 185 backgroundColor: const Color(0xFF1A2028), 186 title: const Text( 187 'What is a handle?', 188 style: TextStyle(color: Colors.white), 189 ), 190 content: const Text( 191 + 'Your handle is your unique identifier ' 192 + 'on the atProto network, like ' 193 + 'alice.bsky.social. If you don\'t have one ' 194 + 'yet, you can create an account at bsky.app.', 195 style: TextStyle(color: Color(0xFFB6C2D2)), 196 ), 197 actions: [
+24 -21
lib/screens/home/feed_screen.dart
··· 39 40 /// Load feed - business logic is now in FeedProvider 41 void _loadFeed() { 42 - final feedProvider = Provider.of<FeedProvider>(context, listen: false); 43 - feedProvider.loadFeed(refresh: true); 44 } 45 46 void _onScroll() { 47 if (_scrollController.position.pixels >= 48 _scrollController.position.maxScrollExtent - 200) { 49 - final feedProvider = Provider.of<FeedProvider>(context, listen: false); 50 - feedProvider.loadMore(); 51 } 52 } 53 ··· 104 required bool isLoadingMore, 105 required bool isAuthenticated, 106 }) { 107 - // Loading state (only show full-screen loader for initial load, not refresh) 108 if (isLoading && posts.isEmpty) { 109 return const Center( 110 child: CircularProgressIndicator(color: Color(0xFFFF6B35)), 111 ); 112 } 113 114 - // Error state (only show full-screen error when no posts loaded yet) 115 - // If we have posts but pagination failed, we'll show the error at the bottom 116 if (error != null && posts.isEmpty) { 117 return Center( 118 child: Padding( ··· 143 const SizedBox(height: 24), 144 ElevatedButton( 145 onPressed: () { 146 - final feedProvider = Provider.of<FeedProvider>( 147 context, 148 listen: false, 149 - ); 150 - feedProvider.retry(); 151 }, 152 style: ElevatedButton.styleFrom( 153 backgroundColor: const Color(0xFFFF6B35), ··· 199 child: ListView.builder( 200 controller: _scrollController, 201 // Add extra item for loading indicator or pagination error 202 - itemCount: posts.length + (isLoadingMore || error != null ? 1 : 0), 203 itemBuilder: (context, index) { 204 // Footer: loading indicator or error message 205 if (index == posts.length) { ··· 241 const SizedBox(height: 12), 242 TextButton( 243 onPressed: () { 244 - final feedProvider = Provider.of<FeedProvider>( 245 context, 246 listen: false, 247 - ); 248 - feedProvider.clearError(); 249 - feedProvider.loadMore(); 250 }, 251 style: TextButton.styleFrom( 252 foregroundColor: const Color(0xFFFF6B35), ··· 261 262 final post = posts[index]; 263 return Semantics( 264 - label: 265 - 'Feed post in ${post.post.community.name} by ${post.post.author.displayName ?? post.post.author.handle}. ${post.post.title ?? ""}', 266 button: true, 267 child: _PostCard(post: post), 268 ); ··· 351 ), 352 ), 353 Text( 354 - 'Posted by ${post.post.author.displayName ?? post.post.author.handle}', 355 style: const TextStyle( 356 color: Color(0xFFB6C2D2), 357 fontSize: 12, ··· 430 @override 431 Widget build(BuildContext context) { 432 // Only show image if thumbnail exists 433 - if (embed.thumb == null) return const SizedBox.shrink(); 434 435 return Container( 436 decoration: BoxDecoration( ··· 443 width: double.infinity, 444 height: 180, 445 fit: BoxFit.cover, 446 - placeholder: 447 - (context, url) => Container( 448 width: double.infinity, 449 height: 180, 450 color: const Color(0xFF1A1F26),
··· 39 40 /// Load feed - business logic is now in FeedProvider 41 void _loadFeed() { 42 + Provider.of<FeedProvider>(context, listen: false).loadFeed(refresh: true); 43 } 44 45 void _onScroll() { 46 if (_scrollController.position.pixels >= 47 _scrollController.position.maxScrollExtent - 200) { 48 + Provider.of<FeedProvider>(context, listen: false).loadMore(); 49 } 50 } 51 ··· 102 required bool isLoadingMore, 103 required bool isAuthenticated, 104 }) { 105 + // Loading state (only show full-screen loader for initial load, 106 + // not refresh) 107 if (isLoading && posts.isEmpty) { 108 return const Center( 109 child: CircularProgressIndicator(color: Color(0xFFFF6B35)), 110 ); 111 } 112 113 + // Error state (only show full-screen error when no posts loaded 114 + // yet). If we have posts but pagination failed, we'll show the error 115 + // at the bottom 116 if (error != null && posts.isEmpty) { 117 return Center( 118 child: Padding( ··· 143 const SizedBox(height: 24), 144 ElevatedButton( 145 onPressed: () { 146 + Provider.of<FeedProvider>( 147 context, 148 listen: false, 149 + ).retry(); 150 }, 151 style: ElevatedButton.styleFrom( 152 backgroundColor: const Color(0xFFFF6B35), ··· 198 child: ListView.builder( 199 controller: _scrollController, 200 // Add extra item for loading indicator or pagination error 201 + itemCount: 202 + posts.length + (isLoadingMore || error != null ? 1 : 0), 203 itemBuilder: (context, index) { 204 // Footer: loading indicator or error message 205 if (index == posts.length) { ··· 241 const SizedBox(height: 12), 242 TextButton( 243 onPressed: () { 244 + Provider.of<FeedProvider>( 245 context, 246 listen: false, 247 + ) 248 + ..clearError() 249 + ..loadMore(); 250 }, 251 style: TextButton.styleFrom( 252 foregroundColor: const Color(0xFFFF6B35), ··· 261 262 final post = posts[index]; 263 return Semantics( 264 + label: 'Feed post in ${post.post.community.name} by ' 265 + '${post.post.author.displayName ?? post.post.author.handle}. ' 266 + '${post.post.title ?? ""}', 267 button: true, 268 child: _PostCard(post: post), 269 ); ··· 352 ), 353 ), 354 Text( 355 + 'Posted by ${post.post.author.displayName ?? '' 356 + '${post.post.author.handle}'}', 357 style: const TextStyle( 358 color: Color(0xFFB6C2D2), 359 fontSize: 12, ··· 432 @override 433 Widget build(BuildContext context) { 434 // Only show image if thumbnail exists 435 + if (embed.thumb == null) { 436 + return const SizedBox.shrink(); 437 + } 438 439 return Container( 440 decoration: BoxDecoration( ··· 447 width: double.infinity, 448 height: 180, 449 fit: BoxFit.cover, 450 + placeholder: (context, url) => Container( 451 width: double.infinity, 452 height: 180, 453 color: const Color(0xFF1A1F26),
+12 -12
lib/services/api_exceptions.dart
··· 2 /// 3 /// Custom exception classes for different types of API failures. 4 /// This allows better error handling and user-friendly error messages. 5 6 /// Base class for all API exceptions 7 class ApiException implements Exception { 8 final String message; 9 final int? statusCode; 10 final dynamic originalError; 11 12 - ApiException(this.message, {this.statusCode, this.originalError}); 13 - 14 @override 15 String toString() => message; 16 } ··· 18 /// Authentication failure (401) 19 /// Token expired, invalid, or missing 20 class AuthenticationException extends ApiException { 21 - AuthenticationException(String message, {dynamic originalError}) 22 - : super(message, statusCode: 401, originalError: originalError); 23 } 24 25 /// Resource not found (404) 26 /// PDS, community, post, or user not found 27 class NotFoundException extends ApiException { 28 - NotFoundException(String message, {dynamic originalError}) 29 - : super(message, statusCode: 404, originalError: originalError); 30 } 31 32 /// Server error (500+) 33 /// Backend or PDS server failure 34 class ServerException extends ApiException { 35 - ServerException(String message, {int? statusCode, dynamic originalError}) 36 - : super(message, statusCode: statusCode, originalError: originalError); 37 } 38 39 /// Network connectivity failure 40 /// No internet, connection refused, timeout 41 class NetworkException extends ApiException { 42 - NetworkException(String message, {dynamic originalError}) 43 - : super(message, statusCode: null, originalError: originalError); 44 } 45 46 /// Federation error 47 /// atProto PDS unreachable or DID resolution failure 48 class FederationException extends ApiException { 49 - FederationException(String message, {dynamic originalError}) 50 - : super(message, statusCode: null, originalError: originalError); 51 }
··· 2 /// 3 /// Custom exception classes for different types of API failures. 4 /// This allows better error handling and user-friendly error messages. 5 + library; 6 7 /// Base class for all API exceptions 8 class ApiException implements Exception { 9 + 10 + ApiException(this.message, {this.statusCode, this.originalError}); 11 final String message; 12 final int? statusCode; 13 final dynamic originalError; 14 15 @override 16 String toString() => message; 17 } ··· 19 /// Authentication failure (401) 20 /// Token expired, invalid, or missing 21 class AuthenticationException extends ApiException { 22 + AuthenticationException(super.message, {super.originalError}) 23 + : super(statusCode: 401); 24 } 25 26 /// Resource not found (404) 27 /// PDS, community, post, or user not found 28 class NotFoundException extends ApiException { 29 + NotFoundException(super.message, {super.originalError}) 30 + : super(statusCode: 404); 31 } 32 33 /// Server error (500+) 34 /// Backend or PDS server failure 35 class ServerException extends ApiException { 36 + ServerException(super.message, {super.statusCode, super.originalError}); 37 } 38 39 /// Network connectivity failure 40 /// No internet, connection refused, timeout 41 class NetworkException extends ApiException { 42 + NetworkException(super.message, {super.originalError}) 43 + : super(statusCode: null); 44 } 45 46 /// Federation error 47 /// atProto PDS unreachable or DID resolution failure 48 class FederationException extends ApiException { 49 + FederationException(super.message, {super.originalError}) 50 + : super(statusCode: null); 51 }
+18 -9
lib/services/coves_api_service.dart
··· 42 } else { 43 if (kDebugMode) { 44 debugPrint( 45 - '⚠️ Token getter returned null - making unauthenticated request', 46 ); 47 } 48 } 49 } else { 50 if (kDebugMode) { 51 debugPrint( 52 - '⚠️ No token getter provided - making unauthenticated request', 53 ); 54 } 55 } ··· 68 ), 69 ); 70 71 - // Add logging interceptor AFTER auth (so it can see the Authorization header) 72 if (kDebugMode) { 73 _dio.interceptors.add( 74 LogInterceptor( ··· 89 /// 90 /// Parameters: 91 /// - [sort]: 'hot', 'top', or 'new' (default: 'hot') 92 - /// - [timeframe]: 'hour', 'day', 'week', 'month', 'year', 'all' (default: 'day' for top sort) 93 /// - [limit]: Number of posts per page (default: 15, max: 50) 94 /// - [cursor]: Pagination cursor from previous response 95 Future<TimelineResponse> getTimeline({ ··· 120 121 if (kDebugMode) { 122 debugPrint( 123 - '✅ Timeline fetched: ${response.data['feed']?.length ?? 0} posts', 124 ); 125 } 126 ··· 162 163 if (kDebugMode) { 164 debugPrint( 165 - '✅ Discover feed fetched: ${response.data['feed']?.length ?? 0} posts', 166 ); 167 } 168 ··· 188 // Handle specific HTTP status codes 189 if (e.response != null) { 190 final statusCode = e.response!.statusCode; 191 - final message = e.response!.data?['error'] ?? e.response!.data?['message']; 192 193 if (statusCode != null) { 194 if (statusCode == 401) { 195 throw AuthenticationException( 196 - message?.toString() ?? 'Authentication failed. Token expired or invalid', 197 originalError: e, 198 ); 199 } else if (statusCode == 404) { 200 throw NotFoundException( 201 - message?.toString() ?? 'Resource not found. PDS or content may not exist', 202 originalError: e, 203 ); 204 } else if (statusCode >= 500) {
··· 42 } else { 43 if (kDebugMode) { 44 debugPrint( 45 + '⚠️ Token getter returned null - ' 46 + 'making unauthenticated request', 47 ); 48 } 49 } 50 } else { 51 if (kDebugMode) { 52 debugPrint( 53 + '⚠️ No token getter provided - ' 54 + 'making unauthenticated request', 55 ); 56 } 57 } ··· 70 ), 71 ); 72 73 + // Add logging interceptor AFTER auth (so it can see the 74 + // Authorization header) 75 if (kDebugMode) { 76 _dio.interceptors.add( 77 LogInterceptor( ··· 92 /// 93 /// Parameters: 94 /// - [sort]: 'hot', 'top', or 'new' (default: 'hot') 95 + /// - [timeframe]: 'hour', 'day', 'week', 'month', 'year', 'all' 96 + /// (default: 'day' for top sort) 97 /// - [limit]: Number of posts per page (default: 15, max: 50) 98 /// - [cursor]: Pagination cursor from previous response 99 Future<TimelineResponse> getTimeline({ ··· 124 125 if (kDebugMode) { 126 debugPrint( 127 + '✅ Timeline fetched: ' 128 + '${response.data['feed']?.length ?? 0} posts', 129 ); 130 } 131 ··· 167 168 if (kDebugMode) { 169 debugPrint( 170 + '✅ Discover feed fetched: ' 171 + '${response.data['feed']?.length ?? 0} posts', 172 ); 173 } 174 ··· 194 // Handle specific HTTP status codes 195 if (e.response != null) { 196 final statusCode = e.response!.statusCode; 197 + final message = 198 + e.response!.data?['error'] ?? e.response!.data?['message']; 199 200 if (statusCode != null) { 201 if (statusCode == 401) { 202 throw AuthenticationException( 203 + message?.toString() ?? 204 + 'Authentication failed. Token expired or invalid', 205 originalError: e, 206 ); 207 } else if (statusCode == 404) { 208 throw NotFoundException( 209 + message?.toString() ?? 210 + 'Resource not found. PDS or content may not exist', 211 originalError: e, 212 ); 213 } else if (statusCode >= 500) {
+15 -8
lib/services/oauth_service.dart
··· 3 import 'package:flutter/foundation.dart'; 4 import '../config/oauth_config.dart'; 5 6 - /// OAuth Service for atProto authentication using the new atproto_oauth_flutter package 7 /// 8 /// Key improvements over the old implementation: 9 - /// ✅ Proper decentralized OAuth discovery - works with ANY PDS (not just bsky.social) 10 /// ✅ Built-in session management - no manual token storage 11 /// ✅ Automatic token refresh with concurrency control 12 /// ✅ Session event streams for updates and deletions 13 - /// ✅ Secure storage handled internally (iOS Keychain, Android EncryptedSharedPreferences) 14 /// 15 /// The new package handles the complete OAuth flow: 16 /// 1. Handle/DID resolution ··· 64 65 /// Set up listeners for session events 66 void _setupEventListeners() { 67 - if (_client == null) return; 68 69 // Listen for session updates (token refresh, etc.) 70 _onUpdatedSubscription = _client!.onUpdated.listen((event) { ··· 121 } 122 123 // Call the new package's signIn method 124 - // This does EVERYTHING: handle resolution, PDS discovery, OAuth flow, token storage 125 if (kDebugMode) { 126 print('📞 Calling FlutterOAuthClient.signIn()...'); 127 } ··· 168 print('$stackTrace'); 169 } 170 171 - // Check if user cancelled (flutter_web_auth_2 throws PlatformException with "CANCELED" code) 172 if (e.toString().contains('CANCELED') || 173 e.toString().contains('User cancelled')) { 174 throw Exception('Sign in cancelled by user'); ··· 196 /// Returns the restored session or null if no session found. 197 Future<OAuthSession?> restoreSession( 198 String did, { 199 - refresh = 'auto', 200 }) async { 201 try { 202 if (_client == null) { ··· 219 } 220 221 return session; 222 - } catch (e) { 223 if (kDebugMode) { 224 print('⚠️ Failed to restore session: $e'); 225 }
··· 3 import 'package:flutter/foundation.dart'; 4 import '../config/oauth_config.dart'; 5 6 + /// OAuth Service for atProto authentication using the new 7 + /// atproto_oauth_flutter package 8 /// 9 /// Key improvements over the old implementation: 10 + /// ✅ Proper decentralized OAuth discovery - works with ANY PDS 11 + /// (not just bsky.social) 12 /// ✅ Built-in session management - no manual token storage 13 /// ✅ Automatic token refresh with concurrency control 14 /// ✅ Session event streams for updates and deletions 15 + /// ✅ Secure storage handled internally 16 + /// (iOS Keychain, Android EncryptedSharedPreferences) 17 /// 18 /// The new package handles the complete OAuth flow: 19 /// 1. Handle/DID resolution ··· 67 68 /// Set up listeners for session events 69 void _setupEventListeners() { 70 + if (_client == null) { 71 + return; 72 + } 73 74 // Listen for session updates (token refresh, etc.) 75 _onUpdatedSubscription = _client!.onUpdated.listen((event) { ··· 126 } 127 128 // Call the new package's signIn method 129 + // This does EVERYTHING: handle resolution, PDS discovery, OAuth flow, 130 + // token storage 131 if (kDebugMode) { 132 print('📞 Calling FlutterOAuthClient.signIn()...'); 133 } ··· 174 print('$stackTrace'); 175 } 176 177 + // Check if user cancelled (flutter_web_auth_2 throws 178 + // PlatformException with "CANCELED" code) 179 if (e.toString().contains('CANCELED') || 180 e.toString().contains('User cancelled')) { 181 throw Exception('Sign in cancelled by user'); ··· 203 /// Returns the restored session or null if no session found. 204 Future<OAuthSession?> restoreSession( 205 String did, { 206 + String refresh = 'auto', 207 }) async { 208 try { 209 if (_client == null) { ··· 226 } 227 228 return session; 229 + } on Exception catch (e) { 230 if (kDebugMode) { 231 print('⚠️ Failed to restore session: $e'); 232 }
+6 -4
lib/services/pds_discovery_service.dart
··· 2 3 /// PDS Discovery Service 4 /// 5 - /// Handles the resolution of atProto handles to their Personal Data Servers (PDS). 6 - /// This is crucial for proper decentralized authentication - each user may be on 7 - /// a different PDS, and we need to redirect them to THEIR PDS's OAuth server. 8 /// 9 /// Flow: 10 /// 1. Resolve handle to DID using a handle resolver (bsky.social) ··· 67 /// Fetch a DID document from the PLC directory 68 Future<Map<String, dynamic>> _fetchDIDDocument(String did) async { 69 try { 70 - final response = await _dio.get('https://plc.directory/$did'); 71 72 if (response.statusCode != 200) { 73 throw Exception('Failed to fetch DID document: ${response.statusCode}');
··· 2 3 /// PDS Discovery Service 4 /// 5 + /// Handles the resolution of atProto handles to their Personal Data 6 + /// Servers (PDS). This is crucial for proper decentralized 7 + /// authentication - each user may be on a different PDS, and we need to 8 + /// redirect them to THEIR PDS's OAuth server. 9 /// 10 /// Flow: 11 /// 1. Resolve handle to DID using a handle resolver (bsky.social) ··· 68 /// Fetch a DID document from the PLC directory 69 Future<Map<String, dynamic>> _fetchDIDDocument(String did) async { 70 try { 71 + final response = 72 + await _dio.get('https://plc.directory/$did'); 73 74 if (response.statusCode != 200) { 75 throw Exception('Failed to fetch DID document: ${response.statusCode}');
+7 -7
lib/widgets/primary_button.dart
··· 26 style: ElevatedButton.styleFrom( 27 backgroundColor: _getBackgroundColor(), 28 foregroundColor: _getTextColor(), 29 - disabledBackgroundColor: _getBackgroundColor().withOpacity(0.5), 30 - disabledForegroundColor: _getTextColor().withOpacity(0.5), 31 overlayColor: _getOverlayColor(), 32 splashFactory: NoSplash.splashFactory, 33 shape: RoundedRectangleBorder( ··· 37 elevation: variant == ButtonVariant.solid ? 8 : 0, 38 shadowColor: 39 variant == ButtonVariant.solid 40 - ? const Color(0xFFD84315).withOpacity(0.4) 41 : Colors.transparent, 42 padding: const EdgeInsets.symmetric(vertical: 12), 43 ), ··· 60 case ButtonVariant.solid: 61 return const Color(0xFFFF6B35); 62 case ButtonVariant.outline: 63 - return const Color(0xFF5A6B7F).withOpacity(0.1); 64 case ButtonVariant.tertiary: 65 return const Color(0xFF1A1F27); 66 } ··· 87 Color _getOverlayColor() { 88 switch (variant) { 89 case ButtonVariant.solid: 90 - return const Color(0xFFD84315).withOpacity(0.2); 91 case ButtonVariant.outline: 92 - return const Color(0xFF5A6B7F).withOpacity(0.15); 93 case ButtonVariant.tertiary: 94 - return const Color(0xFF2A3441).withOpacity(0.3); 95 } 96 } 97 }
··· 26 style: ElevatedButton.styleFrom( 27 backgroundColor: _getBackgroundColor(), 28 foregroundColor: _getTextColor(), 29 + disabledBackgroundColor: _getBackgroundColor().withValues(alpha: 0.5), 30 + disabledForegroundColor: _getTextColor().withValues(alpha: 0.5), 31 overlayColor: _getOverlayColor(), 32 splashFactory: NoSplash.splashFactory, 33 shape: RoundedRectangleBorder( ··· 37 elevation: variant == ButtonVariant.solid ? 8 : 0, 38 shadowColor: 39 variant == ButtonVariant.solid 40 + ? const Color(0xFFD84315).withValues(alpha: 0.4) 41 : Colors.transparent, 42 padding: const EdgeInsets.symmetric(vertical: 12), 43 ), ··· 60 case ButtonVariant.solid: 61 return const Color(0xFFFF6B35); 62 case ButtonVariant.outline: 63 + return const Color(0xFF5A6B7F).withValues(alpha: 0.1); 64 case ButtonVariant.tertiary: 65 return const Color(0xFF1A1F27); 66 } ··· 87 Color _getOverlayColor() { 88 switch (variant) { 89 case ButtonVariant.solid: 90 + return const Color(0xFFD84315).withValues(alpha: 0.2); 91 case ButtonVariant.outline: 92 + return const Color(0xFF5A6B7F).withValues(alpha: 0.15); 93 case ButtonVariant.tertiary: 94 + return const Color(0xFF2A3441).withValues(alpha: 0.3); 95 } 96 } 97 }
+57 -201
pubspec.lock
··· 5 dependency: transitive 6 description: 7 name: _fe_analyzer_shared 8 - sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a 9 url: "https://pub.dev" 10 source: hosted 11 - version: "88.0.0" 12 analyzer: 13 dependency: transitive 14 description: 15 name: analyzer 16 - sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f" 17 url: "https://pub.dev" 18 source: hosted 19 - version: "8.1.1" 20 args: 21 dependency: transitive 22 description: ··· 29 dependency: transitive 30 description: 31 name: async 32 - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 33 url: "https://pub.dev" 34 source: hosted 35 - version: "2.12.0" 36 - at_identifier: 37 - dependency: transitive 38 - description: 39 - name: at_identifier 40 - sha256: "7c8778202d17ec4e63b38a6a58480503fbf0d7fc1d62e0d64580a9b6cbe142f7" 41 - url: "https://pub.dev" 42 - source: hosted 43 - version: "0.2.2" 44 - at_uri: 45 - dependency: transitive 46 - description: 47 - name: at_uri 48 - sha256: "1156d9d70460fcfcb30e744d7f8c7d544eff073b3142b772f0d02aca10dd064f" 49 - url: "https://pub.dev" 50 - source: hosted 51 - version: "0.4.0" 52 - atproto: 53 - dependency: transitive 54 - description: 55 - name: atproto 56 - sha256: "0f3d342c4d629e9994d58dbadd4281074641ac75a18cd514b212a3b15f86019e" 57 - url: "https://pub.dev" 58 - source: hosted 59 - version: "0.13.3" 60 - atproto_core: 61 - dependency: transitive 62 - description: 63 - name: atproto_core 64 - sha256: "13e7f5f0f3d9e5be59eefd5f427adf45ffdeaa59001d4ea7c91764ba21f1e9ba" 65 - url: "https://pub.dev" 66 - source: hosted 67 - version: "0.11.2" 68 - atproto_oauth: 69 - dependency: transitive 70 - description: 71 - name: atproto_oauth 72 - sha256: "8a0c64455c38c45773ebab5fdd55bf214541461f3a97fe0e6184a5eeb8222f03" 73 - url: "https://pub.dev" 74 - source: hosted 75 - version: "0.1.0" 76 atproto_oauth_flutter: 77 dependency: "direct main" 78 description: ··· 80 relative: true 81 source: path 82 version: "0.1.0" 83 - base_codecs: 84 - dependency: transitive 85 - description: 86 - name: base_codecs 87 - sha256: "41701a12ede9912663decd708279924ece5018566daa7d1f484d5f4f10894f91" 88 - url: "https://pub.dev" 89 - source: hosted 90 - version: "1.0.1" 91 - bluesky: 92 - dependency: "direct main" 93 - description: 94 - name: bluesky 95 - sha256: "207135e189278936dfc6bad0d59835a359f06b97ecd73eee1bccf6b993969428" 96 - url: "https://pub.dev" 97 - source: hosted 98 - version: "0.18.10" 99 boolean_selector: 100 dependency: transitive 101 description: ··· 104 url: "https://pub.dev" 105 source: hosted 106 version: "2.1.2" 107 - buffer: 108 - dependency: transitive 109 - description: 110 - name: buffer 111 - sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" 112 - url: "https://pub.dev" 113 - source: hosted 114 - version: "1.2.3" 115 build: 116 dependency: transitive 117 description: ··· 184 url: "https://pub.dev" 185 source: hosted 186 version: "1.3.1" 187 - cbor: 188 - dependency: transitive 189 - description: 190 - name: cbor 191 - sha256: f5239dd6b6ad24df67d1449e87d7180727d6f43b87b3c9402e6398c7a2d9609b 192 - url: "https://pub.dev" 193 - source: hosted 194 - version: "6.3.7" 195 characters: 196 dependency: transitive 197 description: ··· 204 dependency: transitive 205 description: 206 name: checked_yaml 207 - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff 208 url: "https://pub.dev" 209 source: hosted 210 - version: "2.0.3" 211 clock: 212 dependency: transitive 213 description: ··· 256 url: "https://pub.dev" 257 source: hosted 258 version: "1.0.8" 259 - dart_multihash: 260 - dependency: transitive 261 - description: 262 - name: dart_multihash 263 - sha256: "7bef7091497c531f94bf82102805a69d97e4e5d120000dcbbc4a1da679060e0a" 264 - url: "https://pub.dev" 265 - source: hosted 266 - version: "1.0.1" 267 dart_style: 268 dependency: transitive 269 description: ··· 300 dependency: transitive 301 description: 302 name: fake_async 303 - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" 304 url: "https://pub.dev" 305 source: hosted 306 - version: "1.3.2" 307 ffi: 308 dependency: transitive 309 description: ··· 345 dependency: "direct dev" 346 description: 347 name: flutter_lints 348 - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" 349 url: "https://pub.dev" 350 source: hosted 351 - version: "5.0.0" 352 flutter_secure_storage: 353 dependency: "direct main" 354 description: ··· 431 description: flutter 432 source: sdk 433 version: "0.0.0" 434 - freezed_annotation: 435 - dependency: transitive 436 - description: 437 - name: freezed_annotation 438 - sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 439 - url: "https://pub.dev" 440 - source: hosted 441 - version: "2.4.4" 442 glob: 443 dependency: transitive 444 description: ··· 463 url: "https://pub.dev" 464 source: hosted 465 version: "2.3.2" 466 - hex: 467 - dependency: transitive 468 - description: 469 - name: hex 470 - sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" 471 - url: "https://pub.dev" 472 - source: hosted 473 - version: "0.2.0" 474 http: 475 - dependency: transitive 476 description: 477 name: http 478 sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 ··· 495 url: "https://pub.dev" 496 source: hosted 497 version: "4.1.2" 498 - ieee754: 499 - dependency: transitive 500 - description: 501 - name: ieee754 502 - sha256: "7d87451c164a56c156180d34a4e93779372edd191d2c219206100b976203128c" 503 - url: "https://pub.dev" 504 - source: hosted 505 - version: "1.0.3" 506 io: 507 dependency: transitive 508 description: ··· 531 dependency: transitive 532 description: 533 name: leak_tracker 534 - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec 535 url: "https://pub.dev" 536 source: hosted 537 - version: "10.0.8" 538 leak_tracker_flutter_testing: 539 dependency: transitive 540 description: 541 name: leak_tracker_flutter_testing 542 - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 543 url: "https://pub.dev" 544 source: hosted 545 - version: "3.0.9" 546 leak_tracker_testing: 547 dependency: transitive 548 description: 549 name: leak_tracker_testing 550 - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" 551 url: "https://pub.dev" 552 source: hosted 553 - version: "3.0.1" 554 lints: 555 dependency: transitive 556 description: 557 name: lints 558 - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 559 url: "https://pub.dev" 560 source: hosted 561 - version: "5.1.1" 562 logging: 563 dependency: transitive 564 description: ··· 595 dependency: transitive 596 description: 597 name: mime 598 - sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" 599 url: "https://pub.dev" 600 source: hosted 601 - version: "1.0.6" 602 mockito: 603 dependency: "direct dev" 604 description: ··· 607 url: "https://pub.dev" 608 source: hosted 609 version: "5.5.1" 610 - multiformats: 611 - dependency: transitive 612 - description: 613 - name: multiformats 614 - sha256: aa2fa36d2e4d0069dac993b35ee52e5165d67f15b995d68f797466065a6d05a5 615 - url: "https://pub.dev" 616 - source: hosted 617 - version: "0.2.3" 618 - nanoid: 619 - dependency: transitive 620 - description: 621 - name: nanoid 622 - sha256: be3f8752d9046c825df2f3914195151eb876f3ad64b9d833dd0b799b77b8759e 623 - url: "https://pub.dev" 624 - source: hosted 625 - version: "1.0.0" 626 nested: 627 dependency: transitive 628 description: ··· 631 url: "https://pub.dev" 632 source: hosted 633 version: "1.0.0" 634 - nsid: 635 - dependency: transitive 636 - description: 637 - name: nsid 638 - sha256: f0e58c3899f7c224a7c9fb991be5bb2c18de0f920bec4e807ae2d3572cb718c1 639 - url: "https://pub.dev" 640 - source: hosted 641 - version: "0.4.1" 642 octo_image: 643 dependency: transitive 644 description: ··· 683 dependency: transitive 684 description: 685 name: path_provider_android 686 - sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" 687 url: "https://pub.dev" 688 source: hosted 689 - version: "2.2.19" 690 path_provider_foundation: 691 dependency: transitive 692 description: 693 name: path_provider_foundation 694 - sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" 695 url: "https://pub.dev" 696 source: hosted 697 - version: "2.4.2" 698 path_provider_linux: 699 dependency: transitive 700 description: ··· 723 dependency: transitive 724 description: 725 name: petitparser 726 - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" 727 url: "https://pub.dev" 728 source: hosted 729 - version: "6.1.0" 730 platform: 731 dependency: transitive 732 description: ··· 803 dependency: transitive 804 description: 805 name: shared_preferences_android 806 - sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e 807 url: "https://pub.dev" 808 source: hosted 809 - version: "2.4.13" 810 shared_preferences_foundation: 811 dependency: transitive 812 description: 813 name: shared_preferences_foundation 814 - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" 815 url: "https://pub.dev" 816 source: hosted 817 - version: "2.5.4" 818 shared_preferences_linux: 819 dependency: transitive 820 description: ··· 904 dependency: transitive 905 description: 906 name: sqflite_android 907 - sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" 908 url: "https://pub.dev" 909 source: hosted 910 - version: "2.4.1" 911 sqflite_common: 912 dependency: transitive 913 description: 914 name: sqflite_common 915 - sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" 916 url: "https://pub.dev" 917 source: hosted 918 - version: "2.5.5" 919 sqflite_darwin: 920 dependency: transitive 921 description: ··· 968 dependency: transitive 969 description: 970 name: synchronized 971 - sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" 972 url: "https://pub.dev" 973 source: hosted 974 - version: "3.3.1" 975 term_glyph: 976 dependency: transitive 977 description: ··· 984 dependency: transitive 985 description: 986 name: test_api 987 - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd 988 url: "https://pub.dev" 989 source: hosted 990 - version: "0.7.4" 991 typed_data: 992 dependency: transitive 993 description: ··· 996 url: "https://pub.dev" 997 source: hosted 998 version: "1.4.0" 999 - universal_io: 1000 - dependency: transitive 1001 - description: 1002 - name: universal_io 1003 - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" 1004 - url: "https://pub.dev" 1005 - source: hosted 1006 - version: "2.2.2" 1007 url_launcher: 1008 dependency: transitive 1009 description: ··· 1016 dependency: transitive 1017 description: 1018 name: url_launcher_android 1019 - sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" 1020 url: "https://pub.dev" 1021 source: hosted 1022 - version: "6.3.20" 1023 url_launcher_ios: 1024 dependency: transitive 1025 description: 1026 name: url_launcher_ios 1027 - sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 1028 url: "https://pub.dev" 1029 source: hosted 1030 - version: "6.3.4" 1031 url_launcher_linux: 1032 dependency: transitive 1033 description: ··· 1040 dependency: transitive 1041 description: 1042 name: url_launcher_macos 1043 - sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f 1044 url: "https://pub.dev" 1045 source: hosted 1046 - version: "3.2.3" 1047 url_launcher_platform_interface: 1048 dependency: transitive 1049 description: ··· 1104 dependency: transitive 1105 description: 1106 name: vector_math 1107 - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" 1108 url: "https://pub.dev" 1109 source: hosted 1110 - version: "2.1.4" 1111 vm_service: 1112 dependency: transitive 1113 description: 1114 name: vm_service 1115 - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" 1116 url: "https://pub.dev" 1117 source: hosted 1118 - version: "14.3.1" 1119 watcher: 1120 dependency: transitive 1121 description: ··· 1152 dependency: transitive 1153 description: 1154 name: win32 1155 - sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" 1156 url: "https://pub.dev" 1157 source: hosted 1158 - version: "5.13.0" 1159 window_to_front: 1160 dependency: transitive 1161 description: ··· 1176 dependency: transitive 1177 description: 1178 name: xml 1179 - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 1180 - url: "https://pub.dev" 1181 - source: hosted 1182 - version: "6.5.0" 1183 - xrpc: 1184 - dependency: transitive 1185 - description: 1186 - name: xrpc 1187 - sha256: bacfa0f6824fdeaa631aad1a5fd064c3f140c771fed94cbd04df3b7d1e008709 1188 url: "https://pub.dev" 1189 source: hosted 1190 - version: "0.6.1" 1191 yaml: 1192 dependency: transitive 1193 description: ··· 1197 source: hosted 1198 version: "3.1.3" 1199 sdks: 1200 - dart: ">=3.7.2 <4.0.0" 1201 - flutter: ">=3.29.0"
··· 5 dependency: transitive 6 description: 7 name: _fe_analyzer_shared 8 + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d 9 url: "https://pub.dev" 10 source: hosted 11 + version: "91.0.0" 12 analyzer: 13 dependency: transitive 14 description: 15 name: analyzer 16 + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 17 url: "https://pub.dev" 18 source: hosted 19 + version: "8.4.1" 20 args: 21 dependency: transitive 22 description: ··· 29 dependency: transitive 30 description: 31 name: async 32 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" 33 url: "https://pub.dev" 34 source: hosted 35 + version: "2.13.0" 36 atproto_oauth_flutter: 37 dependency: "direct main" 38 description: ··· 40 relative: true 41 source: path 42 version: "0.1.0" 43 boolean_selector: 44 dependency: transitive 45 description: ··· 48 url: "https://pub.dev" 49 source: hosted 50 version: "2.1.2" 51 build: 52 dependency: transitive 53 description: ··· 120 url: "https://pub.dev" 121 source: hosted 122 version: "1.3.1" 123 characters: 124 dependency: transitive 125 description: ··· 132 dependency: transitive 133 description: 134 name: checked_yaml 135 + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" 136 url: "https://pub.dev" 137 source: hosted 138 + version: "2.0.4" 139 clock: 140 dependency: transitive 141 description: ··· 184 url: "https://pub.dev" 185 source: hosted 186 version: "1.0.8" 187 dart_style: 188 dependency: transitive 189 description: ··· 220 dependency: transitive 221 description: 222 name: fake_async 223 + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" 224 url: "https://pub.dev" 225 source: hosted 226 + version: "1.3.3" 227 ffi: 228 dependency: transitive 229 description: ··· 265 dependency: "direct dev" 266 description: 267 name: flutter_lints 268 + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" 269 url: "https://pub.dev" 270 source: hosted 271 + version: "6.0.0" 272 flutter_secure_storage: 273 dependency: "direct main" 274 description: ··· 351 description: flutter 352 source: sdk 353 version: "0.0.0" 354 glob: 355 dependency: transitive 356 description: ··· 375 url: "https://pub.dev" 376 source: hosted 377 version: "2.3.2" 378 http: 379 + dependency: "direct dev" 380 description: 381 name: http 382 sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 ··· 399 url: "https://pub.dev" 400 source: hosted 401 version: "4.1.2" 402 io: 403 dependency: transitive 404 description: ··· 427 dependency: transitive 428 description: 429 name: leak_tracker 430 + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" 431 url: "https://pub.dev" 432 source: hosted 433 + version: "11.0.2" 434 leak_tracker_flutter_testing: 435 dependency: transitive 436 description: 437 name: leak_tracker_flutter_testing 438 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" 439 url: "https://pub.dev" 440 source: hosted 441 + version: "3.0.10" 442 leak_tracker_testing: 443 dependency: transitive 444 description: 445 name: leak_tracker_testing 446 + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" 447 url: "https://pub.dev" 448 source: hosted 449 + version: "3.0.2" 450 lints: 451 dependency: transitive 452 description: 453 name: lints 454 + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 455 url: "https://pub.dev" 456 source: hosted 457 + version: "6.0.0" 458 logging: 459 dependency: transitive 460 description: ··· 491 dependency: transitive 492 description: 493 name: mime 494 + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" 495 url: "https://pub.dev" 496 source: hosted 497 + version: "2.0.0" 498 mockito: 499 dependency: "direct dev" 500 description: ··· 503 url: "https://pub.dev" 504 source: hosted 505 version: "5.5.1" 506 nested: 507 dependency: transitive 508 description: ··· 511 url: "https://pub.dev" 512 source: hosted 513 version: "1.0.0" 514 octo_image: 515 dependency: transitive 516 description: ··· 555 dependency: transitive 556 description: 557 name: path_provider_android 558 + sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 559 url: "https://pub.dev" 560 source: hosted 561 + version: "2.2.20" 562 path_provider_foundation: 563 dependency: transitive 564 description: 565 name: path_provider_foundation 566 + sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 567 url: "https://pub.dev" 568 source: hosted 569 + version: "2.4.3" 570 path_provider_linux: 571 dependency: transitive 572 description: ··· 595 dependency: transitive 596 description: 597 name: petitparser 598 + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" 599 url: "https://pub.dev" 600 source: hosted 601 + version: "7.0.1" 602 platform: 603 dependency: transitive 604 description: ··· 675 dependency: transitive 676 description: 677 name: shared_preferences_android 678 + sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" 679 url: "https://pub.dev" 680 source: hosted 681 + version: "2.4.15" 682 shared_preferences_foundation: 683 dependency: transitive 684 description: 685 name: shared_preferences_foundation 686 + sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" 687 url: "https://pub.dev" 688 source: hosted 689 + version: "2.5.5" 690 shared_preferences_linux: 691 dependency: transitive 692 description: ··· 776 dependency: transitive 777 description: 778 name: sqflite_android 779 + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 780 url: "https://pub.dev" 781 source: hosted 782 + version: "2.4.2+2" 783 sqflite_common: 784 dependency: transitive 785 description: 786 name: sqflite_common 787 + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" 788 url: "https://pub.dev" 789 source: hosted 790 + version: "2.5.6" 791 sqflite_darwin: 792 dependency: transitive 793 description: ··· 840 dependency: transitive 841 description: 842 name: synchronized 843 + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 844 url: "https://pub.dev" 845 source: hosted 846 + version: "3.4.0" 847 term_glyph: 848 dependency: transitive 849 description: ··· 856 dependency: transitive 857 description: 858 name: test_api 859 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" 860 url: "https://pub.dev" 861 source: hosted 862 + version: "0.7.6" 863 typed_data: 864 dependency: transitive 865 description: ··· 868 url: "https://pub.dev" 869 source: hosted 870 version: "1.4.0" 871 url_launcher: 872 dependency: transitive 873 description: ··· 880 dependency: transitive 881 description: 882 name: url_launcher_android 883 + sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" 884 url: "https://pub.dev" 885 source: hosted 886 + version: "6.3.24" 887 url_launcher_ios: 888 dependency: transitive 889 description: 890 name: url_launcher_ios 891 + sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" 892 url: "https://pub.dev" 893 source: hosted 894 + version: "6.3.5" 895 url_launcher_linux: 896 dependency: transitive 897 description: ··· 904 dependency: transitive 905 description: 906 name: url_launcher_macos 907 + sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" 908 url: "https://pub.dev" 909 source: hosted 910 + version: "3.2.4" 911 url_launcher_platform_interface: 912 dependency: transitive 913 description: ··· 968 dependency: transitive 969 description: 970 name: vector_math 971 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b 972 url: "https://pub.dev" 973 source: hosted 974 + version: "2.2.0" 975 vm_service: 976 dependency: transitive 977 description: 978 name: vm_service 979 + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" 980 url: "https://pub.dev" 981 source: hosted 982 + version: "15.0.2" 983 watcher: 984 dependency: transitive 985 description: ··· 1016 dependency: transitive 1017 description: 1018 name: win32 1019 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e 1020 url: "https://pub.dev" 1021 source: hosted 1022 + version: "5.15.0" 1023 window_to_front: 1024 dependency: transitive 1025 description: ··· 1040 dependency: transitive 1041 description: 1042 name: xml 1043 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" 1044 url: "https://pub.dev" 1045 source: hosted 1046 + version: "6.6.1" 1047 yaml: 1048 dependency: transitive 1049 description: ··· 1053 source: hosted 1054 version: "3.1.3" 1055 sdks: 1056 + dart: ">=3.9.0 <4.0.0" 1057 + flutter: ">=3.35.0"
+2 -2
pubspec.yaml
··· 42 go_router: ^16.3.0 43 provider: ^6.1.5+1 44 flutter_svg: ^2.2.1 45 - bluesky: ^0.18.10 46 dio: ^5.9.0 47 cached_network_image: ^3.4.1 48 ··· 55 # activated in the `analysis_options.yaml` file located at the root of your 56 # package. See that file for information about deactivating specific lint 57 # rules and activating additional ones. 58 - flutter_lints: ^5.0.0 59 60 # Testing dependencies 61 mockito: ^5.4.4 ··· 65 # following page: https://dart.dev/tools/pub/pubspec 66 67 # The following section is specific to Flutter packages. 68 flutter: 69 70 # The following line ensures that the Material Icons font is
··· 42 go_router: ^16.3.0 43 provider: ^6.1.5+1 44 flutter_svg: ^2.2.1 45 dio: ^5.9.0 46 cached_network_image: ^3.4.1 47 ··· 54 # activated in the `analysis_options.yaml` file located at the root of your 55 # package. See that file for information about deactivating specific lint 56 # rules and activating additional ones. 57 + flutter_lints: ^6.0.0 58 59 # Testing dependencies 60 mockito: ^5.4.4 ··· 64 # following page: https://dart.dev/tools/pub/pubspec 65 66 # The following section is specific to Flutter packages. 67 + http: any 68 flutter: 69 70 # The following line ensures that the Material Icons font is
+10 -4
test/providers/auth_provider_test.dart
··· 206 // that are not exported from atproto_oauth_flutter package. 207 // These tests would need integration testing or a different approach. 208 209 - test('should return null when not authenticated (skipped - needs integration test)', () async { 210 // This test is skipped as it requires mocking internal OAuth classes 211 // that cannot be mocked with mockito 212 - }, skip: true); 213 214 - test('should sign out user if token refresh fails (skipped - needs integration test)', () async { 215 // This test demonstrates the critical fix for issue #7 216 // Token refresh failure should trigger sign out 217 // Skipped as it requires mocking internal OAuth classes 218 - }, skip: true); 219 }); 220 221 group('State Management', () {
··· 206 // that are not exported from atproto_oauth_flutter package. 207 // These tests would need integration testing or a different approach. 208 209 + test( 210 + 'should return null when not authenticated ' 211 + '(skipped - needs integration test)', 212 + () async { 213 // This test is skipped as it requires mocking internal OAuth classes 214 // that cannot be mocked with mockito 215 + }, skip: true,); 216 217 + test( 218 + 'should sign out user if token refresh fails ' 219 + '(skipped - needs integration test)', 220 + () async { 221 // This test demonstrates the critical fix for issue #7 222 // Token refresh failure should trigger sign out 223 // Skipped as it requires mocking internal OAuth classes 224 + }, skip: true,); 225 }); 226 227 group('State Management', () {
+1 -3
test/providers/feed_provider_test.dart
··· 193 sort: anyNamed('sort'), 194 timeframe: anyNamed('timeframe'), 195 limit: anyNamed('limit'), 196 - cursor: null, 197 ), 198 ).thenAnswer((_) async => refreshResponse); 199 ··· 277 sort: anyNamed('sort'), 278 timeframe: anyNamed('timeframe'), 279 limit: anyNamed('limit'), 280 - cursor: null, 281 ), 282 ).thenAnswer((_) async => firstResponse); 283 ··· 304 }); 305 306 test('should not load more if already loading', () async { 307 - feedProvider.fetchTimeline(refresh: true); 308 await feedProvider.loadMore(); 309 310 // Should not make additional calls while loading
··· 193 sort: anyNamed('sort'), 194 timeframe: anyNamed('timeframe'), 195 limit: anyNamed('limit'), 196 ), 197 ).thenAnswer((_) async => refreshResponse); 198 ··· 276 sort: anyNamed('sort'), 277 timeframe: anyNamed('timeframe'), 278 limit: anyNamed('limit'), 279 ), 280 ).thenAnswer((_) async => firstResponse); 281 ··· 302 }); 303 304 test('should not load more if already loading', () async { 305 + await feedProvider.fetchTimeline(refresh: true); 306 await feedProvider.loadMore(); 307 308 // Should not make additional calls while loading
+17 -13
test/widgets/feed_screen_test.dart
··· 17 @override 18 bool get isLoading => _isLoading; 19 20 - void setAuthenticated(bool value) { 21 _isAuthenticated = value; 22 notifyListeners(); 23 } 24 25 - void setLoading(bool value) { 26 _isLoading = value; 27 notifyListeners(); 28 } ··· 63 notifyListeners(); 64 } 65 66 - void setLoading(bool value) { 67 _isLoading = value; 68 notifyListeners(); 69 } 70 71 - void setLoadingMore(bool value) { 72 _isLoadingMore = value; 73 notifyListeners(); 74 } ··· 78 notifyListeners(); 79 } 80 81 - void setHasMore(bool value) { 82 _hasMore = value; 83 notifyListeners(); 84 } ··· 122 testWidgets('should display loading indicator when loading', ( 123 tester, 124 ) async { 125 - fakeFeedProvider.setLoading(true); 126 127 await tester.pumpWidget(createTestWidget()); 128 ··· 136 137 expect(find.text('Failed to load feed'), findsOneWidget); 138 // Error message is transformed to user-friendly message 139 - expect(find.text('Please check your internet connection'), findsOneWidget); 140 expect(find.text('Retry'), findsOneWidget); 141 142 // Test retry button ··· 148 149 testWidgets('should display empty state when no posts', (tester) async { 150 fakeFeedProvider.setPosts([]); 151 - fakeAuthProvider.setAuthenticated(false); 152 153 await tester.pumpWidget(createTestWidget()); 154 ··· 160 tester, 161 ) async { 162 fakeFeedProvider.setPosts([]); 163 - fakeAuthProvider.setAuthenticated(true); 164 165 await tester.pumpWidget(createTestWidget()); 166 ··· 188 testWidgets('should display "Feed" title when authenticated', ( 189 tester, 190 ) async { 191 - fakeAuthProvider.setAuthenticated(true); 192 193 await tester.pumpWidget(createTestWidget()); 194 ··· 198 testWidgets('should display "Explore" title when not authenticated', ( 199 tester, 200 ) async { 201 - fakeAuthProvider.setAuthenticated(false); 202 203 await tester.pumpWidget(createTestWidget()); 204 ··· 223 tester, 224 ) async { 225 final mockPosts = [_createMockPost('Test Post')]; 226 - fakeFeedProvider.setPosts(mockPosts); 227 - fakeFeedProvider.setLoadingMore(true); 228 229 await tester.pumpWidget(createTestWidget()); 230
··· 17 @override 18 bool get isLoading => _isLoading; 19 20 + void setAuthenticated({required bool value}) { 21 _isAuthenticated = value; 22 notifyListeners(); 23 } 24 25 + void setLoading({required bool value}) { 26 _isLoading = value; 27 notifyListeners(); 28 } ··· 63 notifyListeners(); 64 } 65 66 + void setLoading({required bool value}) { 67 _isLoading = value; 68 notifyListeners(); 69 } 70 71 + void setLoadingMore({required bool value}) { 72 _isLoadingMore = value; 73 notifyListeners(); 74 } ··· 78 notifyListeners(); 79 } 80 81 + void setHasMore({required bool value}) { 82 _hasMore = value; 83 notifyListeners(); 84 } ··· 122 testWidgets('should display loading indicator when loading', ( 123 tester, 124 ) async { 125 + fakeFeedProvider.setLoading(value: true); 126 127 await tester.pumpWidget(createTestWidget()); 128 ··· 136 137 expect(find.text('Failed to load feed'), findsOneWidget); 138 // Error message is transformed to user-friendly message 139 + expect( 140 + find.text('Please check your internet connection'), 141 + findsOneWidget, 142 + ); 143 expect(find.text('Retry'), findsOneWidget); 144 145 // Test retry button ··· 151 152 testWidgets('should display empty state when no posts', (tester) async { 153 fakeFeedProvider.setPosts([]); 154 + fakeAuthProvider.setAuthenticated(value: false); 155 156 await tester.pumpWidget(createTestWidget()); 157 ··· 163 tester, 164 ) async { 165 fakeFeedProvider.setPosts([]); 166 + fakeAuthProvider.setAuthenticated(value: true); 167 168 await tester.pumpWidget(createTestWidget()); 169 ··· 191 testWidgets('should display "Feed" title when authenticated', ( 192 tester, 193 ) async { 194 + fakeAuthProvider.setAuthenticated(value: true); 195 196 await tester.pumpWidget(createTestWidget()); 197 ··· 201 testWidgets('should display "Explore" title when not authenticated', ( 202 tester, 203 ) async { 204 + fakeAuthProvider.setAuthenticated(value: false); 205 206 await tester.pumpWidget(createTestWidget()); 207 ··· 226 tester, 227 ) async { 228 final mockPosts = [_createMockPost('Test Post')]; 229 + fakeFeedProvider 230 + ..setPosts(mockPosts) 231 + ..setLoadingMore(value: true); 232 233 await tester.pumpWidget(createTestWidget()); 234