[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

perf: reduce redundant network calls

+866 -930
-78
ios/Podfile.lock
··· 1 1 PODS: 2 2 - audio_waveforms (0.0.1): 3 3 - Flutter 4 - - audioplayers_darwin (0.0.1): 5 - - Flutter 6 - - FlutterMacOS 7 4 - better_player_plus (1.1.2): 8 5 - Cache (~> 6.0.0) 9 6 - Flutter ··· 11 8 - HLSCachingReverseProxyServer 12 9 - PINCache 13 10 - Cache (6.0.0) 14 - - camera_avfoundation (0.0.1): 15 - - Flutter 16 11 - Flutter (1.0.0) 17 - - flutter_secure_storage_darwin (10.0.0): 18 - - Flutter 19 - - FlutterMacOS 20 - - flutter_web_auth_2 (3.0.0): 21 - - Flutter 22 12 - fvp (0.35.2): 23 13 - Flutter 24 14 - FlutterMacOS ··· 29 19 - HLSCachingReverseProxyServer (0.1.0): 30 20 - GCDWebServer (~> 3.5) 31 21 - PINCache (>= 3.0.1-beta.3) 32 - - image_picker_ios (0.0.1): 33 - - Flutter 34 22 - mdk (0.35.1) 35 - - package_info_plus (0.4.5): 36 - - Flutter 37 - - path_provider_foundation (0.0.1): 38 - - Flutter 39 - - FlutterMacOS 40 23 - PINCache (3.0.4): 41 24 - PINCache/Arc-exception-safe (= 3.0.4) 42 25 - PINCache/Core (= 3.0.4) ··· 52 35 - PostHog (< 4.0.0, >= 3.32.0) 53 36 - pro_video_editor (0.0.1): 54 37 - Flutter 55 - - shared_preferences_foundation (0.0.1): 56 - - Flutter 57 - - FlutterMacOS 58 - - sqflite_darwin (0.0.4): 59 - - Flutter 60 - - FlutterMacOS 61 - - url_launcher_ios (0.0.1): 62 - - Flutter 63 - - video_player_avfoundation (0.0.1): 64 - - Flutter 65 - - FlutterMacOS 66 - - wakelock_plus (0.0.1): 67 - - Flutter 68 38 69 39 DEPENDENCIES: 70 40 - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) 71 - - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) 72 41 - better_player_plus (from `.symlinks/plugins/better_player_plus/ios`) 73 - - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) 74 42 - Flutter (from `Flutter`) 75 - - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) 76 - - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) 77 43 - fvp (from `.symlinks/plugins/fvp/darwin`) 78 - - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) 79 - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) 80 - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) 81 44 - posthog_flutter (from `.symlinks/plugins/posthog_flutter/ios`) 82 45 - pro_video_editor (from `.symlinks/plugins/pro_video_editor/ios`) 83 - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) 84 - - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) 85 - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) 86 - - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) 87 - - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) 88 46 89 47 SPEC REPOS: 90 48 trunk: ··· 99 57 EXTERNAL SOURCES: 100 58 audio_waveforms: 101 59 :path: ".symlinks/plugins/audio_waveforms/ios" 102 - audioplayers_darwin: 103 - :path: ".symlinks/plugins/audioplayers_darwin/darwin" 104 60 better_player_plus: 105 61 :path: ".symlinks/plugins/better_player_plus/ios" 106 - camera_avfoundation: 107 - :path: ".symlinks/plugins/camera_avfoundation/ios" 108 62 Flutter: 109 63 :path: Flutter 110 - flutter_secure_storage_darwin: 111 - :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" 112 - flutter_web_auth_2: 113 - :path: ".symlinks/plugins/flutter_web_auth_2/ios" 114 64 fvp: 115 65 :path: ".symlinks/plugins/fvp/darwin" 116 - image_picker_ios: 117 - :path: ".symlinks/plugins/image_picker_ios/ios" 118 - package_info_plus: 119 - :path: ".symlinks/plugins/package_info_plus/ios" 120 - path_provider_foundation: 121 - :path: ".symlinks/plugins/path_provider_foundation/darwin" 122 66 posthog_flutter: 123 67 :path: ".symlinks/plugins/posthog_flutter/ios" 124 68 pro_video_editor: 125 69 :path: ".symlinks/plugins/pro_video_editor/ios" 126 - shared_preferences_foundation: 127 - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" 128 - sqflite_darwin: 129 - :path: ".symlinks/plugins/sqflite_darwin/darwin" 130 - url_launcher_ios: 131 - :path: ".symlinks/plugins/url_launcher_ios/ios" 132 - video_player_avfoundation: 133 - :path: ".symlinks/plugins/video_player_avfoundation/darwin" 134 - wakelock_plus: 135 - :path: ".symlinks/plugins/wakelock_plus/ios" 136 70 137 71 SPEC CHECKSUMS: 138 72 audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf 139 - audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd 140 73 better_player_plus: 3d40145c650bb83dde08f0d593b21a144196769f 141 74 Cache: 4ca7e00363fca5455f26534e5607634c820ffc2d 142 - camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 143 75 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 144 - flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 145 - flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 146 76 fvp: 4fe77c2b30122233a57f425dafd219ed35565809 147 77 GCDWebServer: 2c156a56c8226e2d5c0c3f208a3621ccffbe3ce4 148 78 HLSCachingReverseProxyServer: 59935e1e0244ad7f3375d75b5ef46e8eb26ab181 149 - image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 150 79 mdk: 59bbe9e2ac2a052455ab1b076c245680d66cf6c0 151 - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 152 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 153 80 PINCache: d9a87a0ff397acffe9e2f0db972ac14680441158 154 81 PINOperation: fb563bcc9c32c26d6c78aaff967d405aa2ee74a7 155 82 PostHog: cf23456d5de1c19efe5823437069440a12198c91 156 83 posthog_flutter: 9535ac2d4ab65ccb9ace3886dcc0b3105198bad5 157 84 pro_video_editor: 44ef9a6d48dbd757ed428cf35396dd05f35c7830 158 - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb 159 - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 160 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b 161 - video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a 162 - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 163 85 164 86 PODFILE CHECKSUM: e716c32704b29904c5fe233535ab45faecf8c549 165 87
+22
ios/Runner.xcodeproj/project.pbxproj
··· 17 17 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 18 18 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 19 19 C788453B52B48F95B80D3F19 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E62B4B048ACF350C4CFD2DF /* Pods_RunnerTests.framework */; }; 20 + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 20 21 /* End PBXBuildFile section */ 21 22 22 23 /* Begin PBXContainerItemProxy section */ ··· 68 69 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 69 70 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 70 71 E84053FC439008C2EBB39FE0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 72 + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; }; 71 73 /* End PBXFileReference section */ 72 74 73 75 /* Begin PBXFrameworksBuildPhase section */ ··· 75 77 isa = PBXFrameworksBuildPhase; 76 78 buildActionMask = 2147483647; 77 79 files = ( 80 + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 78 81 22D5B0E9A1860BEEC6084203 /* Pods_Runner.framework in Frameworks */, 79 82 ); 80 83 runOnlyForDeploymentPostprocessing = 0; ··· 101 104 9740EEB11CF90186004384FC /* Flutter */ = { 102 105 isa = PBXGroup; 103 106 children = ( 107 + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 104 108 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 105 109 9740EEB21CF90195004384FC /* Debug.xcconfig */, 106 110 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, ··· 192 196 productType = "com.apple.product-type.bundle.unit-test"; 193 197 }; 194 198 97C146ED1CF9000F007C117D /* Runner */ = { 199 + packageProductDependencies = ( 200 + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, 201 + ); 195 202 isa = PBXNativeTarget; 196 203 buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; 197 204 buildPhases = ( ··· 217 224 218 225 /* Begin PBXProject section */ 219 226 97C146E61CF9000F007C117D /* Project object */ = { 227 + packageReferences = ( 228 + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, 229 + ); 220 230 isa = PBXProject; 221 231 attributes = { 222 232 BuildIndependentTargetsInParallel = YES; ··· 746 756 defaultConfigurationName = Release; 747 757 }; 748 758 /* End XCConfigurationList section */ 759 + /* Begin XCLocalSwiftPackageReference section */ 760 + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { 761 + isa = XCLocalSwiftPackageReference; 762 + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; 763 + }; 764 + /* End XCLocalSwiftPackageReference section */ 765 + /* Begin XCSwiftPackageProductDependency section */ 766 + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { 767 + isa = XCSwiftPackageProductDependency; 768 + productName = FlutterGeneratedPluginSwiftPackage; 769 + }; 770 + /* End XCSwiftPackageProductDependency section */ 749 771 }; 750 772 rootObject = 97C146E61CF9000F007C117D /* Project object */; 751 773 }
+18
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
··· 5 5 <BuildAction 6 6 parallelizeBuildables = "YES" 7 7 buildImplicitDependencies = "YES"> 8 + <PreActions> 9 + <ExecutionAction 10 + ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction"> 11 + <ActionContent 12 + title = "Run Prepare Flutter Framework Script" 13 + scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;"> 14 + <EnvironmentBuildable> 15 + <BuildableReference 16 + BuildableIdentifier = "primary" 17 + BlueprintIdentifier = "97C146ED1CF9000F007C117D" 18 + BuildableName = "Runner.app" 19 + BlueprintName = "Runner" 20 + ReferencedContainer = "container:Runner.xcodeproj"> 21 + </BuildableReference> 22 + </EnvironmentBuildable> 23 + </ActionContent> 24 + </ExecutionAction> 25 + </PreActions> 8 26 <BuildActionEntries> 9 27 <BuildActionEntry 10 28 buildForTesting = "YES"
+90
lib/src/core/auth/data/models/account.dart
··· 1 + import 'dart:convert'; 2 + 3 + /// Represents a stored user account with all authentication data 4 + class Account { 5 + const Account({ 6 + required this.accessToken, 7 + required this.refreshToken, 8 + required this.publicKey, 9 + required this.privateKey, 10 + this.dpopNonce, 11 + this.expiresAt, 12 + this.did, 13 + this.handle, 14 + this.pdsEndpoint, 15 + this.server, 16 + }); 17 + 18 + factory Account.fromJson(Map<String, dynamic> json) { 19 + return Account( 20 + accessToken: json['accessToken'] as String, 21 + refreshToken: json['refreshToken'] as String, 22 + publicKey: json['publicKey'] as String, 23 + privateKey: json['privateKey'] as String, 24 + dpopNonce: json['dpopNonce'] as String?, 25 + expiresAt: json['expiresAt'] as String?, 26 + did: json['did'] as String?, 27 + handle: json['handle'] as String?, 28 + pdsEndpoint: json['pdsEndpoint'] as String?, 29 + server: json['server'] as String?, 30 + ); 31 + } 32 + 33 + factory Account.fromJsonString(String jsonString) { 34 + return Account.fromJson(json.decode(jsonString) as Map<String, dynamic>); 35 + } 36 + 37 + final String accessToken; 38 + final String refreshToken; 39 + final String publicKey; 40 + final String privateKey; 41 + final String? dpopNonce; 42 + final String? expiresAt; 43 + final String? did; 44 + final String? handle; 45 + final String? pdsEndpoint; 46 + final String? server; 47 + 48 + Map<String, dynamic> toJson() { 49 + return { 50 + 'accessToken': accessToken, 51 + 'refreshToken': refreshToken, 52 + 'publicKey': publicKey, 53 + 'privateKey': privateKey, 54 + 'dpopNonce': dpopNonce, 55 + 'expiresAt': expiresAt, 56 + 'did': did, 57 + 'handle': handle, 58 + 'pdsEndpoint': pdsEndpoint, 59 + 'server': server, 60 + }; 61 + } 62 + 63 + String toJsonString() => json.encode(toJson()); 64 + 65 + Account copyWith({ 66 + String? accessToken, 67 + String? refreshToken, 68 + String? publicKey, 69 + String? privateKey, 70 + String? dpopNonce, 71 + String? expiresAt, 72 + String? did, 73 + String? handle, 74 + String? pdsEndpoint, 75 + String? server, 76 + }) { 77 + return Account( 78 + accessToken: accessToken ?? this.accessToken, 79 + refreshToken: refreshToken ?? this.refreshToken, 80 + publicKey: publicKey ?? this.publicKey, 81 + privateKey: privateKey ?? this.privateKey, 82 + dpopNonce: dpopNonce ?? this.dpopNonce, 83 + expiresAt: expiresAt ?? this.expiresAt, 84 + did: did ?? this.did, 85 + handle: handle ?? this.handle, 86 + pdsEndpoint: pdsEndpoint ?? this.pdsEndpoint, 87 + server: server ?? this.server, 88 + ); 89 + } 90 + }
+82 -135
lib/src/core/auth/data/repositories/auth_repository_impl.dart
··· 6 6 import 'package:atproto_core/atproto_oauth.dart'; 7 7 import 'package:get_it/get_it.dart'; 8 8 import 'package:http/http.dart' as http; 9 + import 'package:spark/src/core/auth/data/models/account.dart'; 9 10 import 'package:spark/src/core/auth/data/models/login_result.dart'; 10 11 import 'package:spark/src/core/auth/data/repositories/auth_repository.dart'; 11 12 import 'package:spark/src/core/storage/storage.dart'; ··· 16 17 17 18 /// OAuth client metadata URL 18 19 const String _clientMetadataUrl = 'https://sprk.so/oauth-client-metadata.json'; 20 + 21 + /// Cached OAuth client metadata to avoid repeated network calls 22 + OAuthClientMetadata? _cachedClientMetadata; 19 23 20 24 /// Implementation of the authentication repository for AT Protocol using OAuth 21 25 class AuthRepositoryImpl implements AuthRepository { ··· 43 47 44 48 @override 45 49 Future<void> get initializationComplete => _initCompleter.future; 50 + 51 + /// Gets cached OAuth client metadata, fetching once if needed 52 + Future<OAuthClientMetadata> _getCachedClientMetadata() async { 53 + if (_cachedClientMetadata != null) { 54 + return _cachedClientMetadata!; 55 + } 56 + _cachedClientMetadata = await getClientMetadata(_clientMetadataUrl); 57 + return _cachedClientMetadata!; 58 + } 46 59 47 60 @override 48 61 bool get isAuthenticated => ··· 105 118 106 119 Future<void> _loadSavedSession() async { 107 120 try { 108 - _logger.d('Loading saved OAuth session'); 121 + _logger.d('Loading saved account'); 109 122 110 - final accessToken = await StorageManager.instance.secure.getString( 111 - StorageKeys.oauthAccessToken, 112 - ); 113 - final savedRefreshToken = await StorageManager.instance.secure.getString( 114 - StorageKeys.oauthRefreshToken, 115 - ); 116 - final publicKey = await StorageManager.instance.secure.getString( 117 - StorageKeys.oauthPublicKey, 118 - ); 119 - final privateKey = await StorageManager.instance.secure.getString( 120 - StorageKeys.oauthPrivateKey, 121 - ); 122 - final savedDpopNonce = await StorageManager.instance.secure.getString( 123 - StorageKeys.oauthDpopNonce, 124 - ); 125 - final savedExpiresAt = await StorageManager.instance.secure.getString( 126 - StorageKeys.oauthExpiresAt, 127 - ); 128 - final savedDid = await StorageManager.instance.secure.getString( 129 - StorageKeys.oauthDid, 130 - ); 131 - final savedHandle = await StorageManager.instance.secure.getString( 132 - StorageKeys.oauthHandle, 133 - ); 134 - final savedPdsEndpoint = await StorageManager.instance.secure.getString( 135 - StorageKeys.oauthPdsEndpoint, 136 - ); 137 - final savedOAuthServer = await StorageManager.instance.secure.getString( 138 - StorageKeys.oauthServer, 123 + // Load account as single JSON object - much faster than multiple reads 124 + final accountJson = await StorageManager.instance.secure.getString( 125 + StorageKeys.account, 139 126 ); 140 127 141 - if (accessToken == null || 142 - savedRefreshToken == null || 143 - publicKey == null || 144 - privateKey == null) { 145 - _logger.d('No saved OAuth session found'); 128 + if (accountJson == null) { 129 + _logger.d('No saved account found'); 146 130 return; 147 131 } 148 132 133 + final account = Account.fromJsonString(accountJson); 134 + 149 135 _oauthSession = restoreOAuthSession( 150 - accessToken: accessToken, 151 - refreshToken: savedRefreshToken, 152 - dPoPNonce: savedDpopNonce, 153 - publicKey: publicKey, 154 - privateKey: privateKey, 136 + accessToken: account.accessToken, 137 + refreshToken: account.refreshToken, 138 + dPoPNonce: account.dpopNonce, 139 + publicKey: account.publicKey, 140 + privateKey: account.privateKey, 155 141 ); 156 142 157 143 // Parse expiresAt, default to epoch if not found (will trigger refresh) 158 - final expiresAt = savedExpiresAt != null 159 - ? DateTime.parse(savedExpiresAt) 144 + final expiresAt = account.expiresAt != null 145 + ? DateTime.parse(account.expiresAt!) 160 146 : DateTime.fromMillisecondsSinceEpoch(0); 161 147 162 - _did = savedDid; 163 - _handle = savedHandle; 164 - _pdsEndpoint = savedPdsEndpoint; 165 - _oauthServer = savedOAuthServer; 166 - 167 - // Recreate OAuth client for token refresh (needed before refresh attempt) 168 - if (_oauthServer != null) { 169 - final metadata = await getClientMetadata(_clientMetadataUrl); 170 - _oauthClient = OAuthClient(metadata, service: _oauthServer!); 171 - _logger.d('OAuthClient recreated for session refresh'); 172 - } 148 + _did = account.did; 149 + _handle = account.handle; 150 + _pdsEndpoint = account.pdsEndpoint; 151 + _oauthServer = account.server; 173 152 174 153 // Check if token needs refresh (5 minutes before expiration per README) 175 - if (expiresAt.isBefore(DateTime.now().add(const Duration(minutes: 5)))) { 154 + final tokenNeedsRefresh = expiresAt.isBefore( 155 + DateTime.now().add(const Duration(minutes: 5)), 156 + ); 157 + 158 + // Only fetch OAuth client metadata if we need to refresh the token 159 + // This avoids a blocking network call on app start when token is valid 160 + if (tokenNeedsRefresh && _oauthServer != null) { 161 + final metadata = await _getCachedClientMetadata(); 162 + _oauthClient = OAuthClient(metadata, service: _oauthServer!); 176 163 _logger.d('Access token expired or expiring soon, refreshing'); 177 164 final refreshed = await refreshToken(); 178 165 if (!refreshed) { ··· 200 187 service: pdsHost, 201 188 ); 202 189 203 - _logger.i('OAuth session loaded successfully for user: $_handle'); 190 + _logger.i('Account loaded successfully for user: $_handle'); 204 191 } catch (e) { 205 - _logger.e('Error loading saved OAuth session', error: e); 192 + _logger.e('Error loading saved account', error: e); 206 193 } 207 194 } 208 195 ··· 210 197 if (_oauthSession == null) return; 211 198 212 199 try { 213 - _logger.d('Saving OAuth session for user: $_handle'); 214 - await StorageManager.instance.secure.setString( 215 - StorageKeys.oauthAccessToken, 216 - _oauthSession!.accessToken, 217 - ); 218 - await StorageManager.instance.secure.setString( 219 - StorageKeys.oauthRefreshToken, 220 - _oauthSession!.refreshToken, 200 + _logger.d('Saving account for user: $_handle'); 201 + 202 + final account = Account( 203 + accessToken: _oauthSession!.accessToken, 204 + refreshToken: _oauthSession!.refreshToken, 205 + publicKey: _oauthSession!.$publicKey, 206 + privateKey: _oauthSession!.$privateKey, 207 + dpopNonce: _oauthSession!.$dPoPNonce, 208 + expiresAt: _oauthSession!.expiresAt.toIso8601String(), 209 + did: _did, 210 + handle: _handle, 211 + pdsEndpoint: _pdsEndpoint, 212 + server: _oauthServer, 221 213 ); 214 + 222 215 await StorageManager.instance.secure.setString( 223 - StorageKeys.oauthPublicKey, 224 - _oauthSession!.$publicKey, 225 - ); 226 - await StorageManager.instance.secure.setString( 227 - StorageKeys.oauthPrivateKey, 228 - _oauthSession!.$privateKey, 229 - ); 230 - await StorageManager.instance.secure.setString( 231 - StorageKeys.oauthDpopNonce, 232 - _oauthSession!.$dPoPNonce, 233 - ); 234 - await StorageManager.instance.secure.setString( 235 - StorageKeys.oauthExpiresAt, 236 - _oauthSession!.expiresAt.toIso8601String(), 216 + StorageKeys.account, 217 + account.toJsonString(), 237 218 ); 238 - if (_did != null) { 239 - await StorageManager.instance.secure.setString( 240 - StorageKeys.oauthDid, 241 - _did!, 242 - ); 243 - } 244 - if (_handle != null) { 245 - await StorageManager.instance.secure.setString( 246 - StorageKeys.oauthHandle, 247 - _handle!, 248 - ); 249 - } 250 - if (_pdsEndpoint != null) { 251 - await StorageManager.instance.secure.setString( 252 - StorageKeys.oauthPdsEndpoint, 253 - _pdsEndpoint!, 254 - ); 255 - } 256 - if (_oauthServer != null) { 257 - await StorageManager.instance.secure.setString( 258 - StorageKeys.oauthServer, 259 - _oauthServer!, 260 - ); 261 - } 262 - _logger.d('OAuth session saved successfully'); 219 + 220 + _logger.d('Account saved successfully'); 263 221 } catch (e) { 264 - _logger.e('Failed to save OAuth session', error: e); 222 + _logger.e('Failed to save account', error: e); 265 223 } 266 224 } 267 225 268 226 Future<void> _clearSavedSession() async { 269 227 try { 270 - _logger.d('Clearing saved OAuth session'); 271 - await StorageManager.instance.secure.remove(StorageKeys.oauthAccessToken); 272 - await StorageManager.instance.secure.remove( 273 - StorageKeys.oauthRefreshToken, 274 - ); 275 - await StorageManager.instance.secure.remove(StorageKeys.oauthPublicKey); 276 - await StorageManager.instance.secure.remove(StorageKeys.oauthPrivateKey); 277 - await StorageManager.instance.secure.remove(StorageKeys.oauthDpopNonce); 278 - await StorageManager.instance.secure.remove(StorageKeys.oauthExpiresAt); 279 - await StorageManager.instance.secure.remove(StorageKeys.oauthDid); 280 - await StorageManager.instance.secure.remove(StorageKeys.oauthHandle); 281 - await StorageManager.instance.secure.remove(StorageKeys.oauthPdsEndpoint); 282 - await StorageManager.instance.secure.remove(StorageKeys.oauthServer); 228 + _logger.d('Clearing saved account'); 229 + await StorageManager.instance.secure.remove(StorageKeys.account); 283 230 await StorageManager.instance.secure.remove( 284 - StorageKeys.oauthPendingContext, 231 + StorageKeys.pendingAuthContext, 285 232 ); 286 233 // Also clear old session format if exists 287 234 await StorageManager.instance.secure.remove(StorageKeys.userSession); 288 - _logger.d('OAuth session cleared successfully'); 235 + _logger.d('Account cleared successfully'); 289 236 } catch (e) { 290 - _logger.e('Failed to clear OAuth session', error: e); 237 + _logger.e('Failed to clear account', error: e); 291 238 } 292 239 } 293 240 ··· 318 265 _handle = handle; 319 266 _pdsEndpoint = pdsEndpoint; 320 267 321 - // Get client metadata 322 - final metadata = await getClientMetadata(_clientMetadataUrl); 268 + // Get client metadata (cached) 269 + final metadata = await _getCachedClientMetadata(); 323 270 // Resolve OAuth server from PDS endpoint 324 271 _oauthServer = await resolveOAuthServer(pdsEndpoint); 325 272 _logger.d('Resolved OAuth server: $_oauthServer'); ··· 335 282 336 283 // Store pending context in case app is killed during OAuth flow 337 284 await StorageManager.instance.secure.setString( 338 - StorageKeys.oauthPendingContext, 285 + StorageKeys.pendingAuthContext, 339 286 json.encode({ 340 287 'handle': handle, 341 288 'did': resolvedDid, 342 289 'pdsEndpoint': pdsEndpoint, 343 - 'oauthServer': _oauthServer, 290 + 'server': _oauthServer, 344 291 'state': stateParam, 345 292 }), 346 293 ); ··· 362 309 _pdsEndpoint = 'https://$service'; 363 310 _oauthServer = service; 364 311 365 - // Get client metadata 366 - final metadata = await getClientMetadata(_clientMetadataUrl); 312 + // Get client metadata (cached) 313 + final metadata = await _getCachedClientMetadata(); 367 314 _logger.d('Using OAuth server: $service'); 368 315 _oauthClient = OAuthClient(metadata, service: _oauthServer!); 369 316 ··· 377 324 378 325 // Store pending context in case app was killed during OAuth flow 379 326 await StorageManager.instance.secure.setString( 380 - StorageKeys.oauthPendingContext, 327 + StorageKeys.pendingAuthContext, 381 328 json.encode({ 382 329 'pdsEndpoint': _pdsEndpoint, 383 - 'oauthServer': _oauthServer, 330 + 'server': _oauthServer, 384 331 'state': stateParam, 385 332 }), 386 333 ); ··· 399 346 if (_oauthClient == null || _pendingContext == null) { 400 347 // Try to restore context if app was killed 401 348 final savedContext = await StorageManager.instance.secure.getString( 402 - StorageKeys.oauthPendingContext, 349 + StorageKeys.pendingAuthContext, 403 350 ); 404 351 if (savedContext != null) { 405 352 final contextData = json.decode(savedContext) as Map<String, dynamic>; 406 353 _handle = contextData['handle'] as String?; 407 354 _did = contextData['did'] as String?; 408 355 _pdsEndpoint = contextData['pdsEndpoint'] as String?; 409 - _oauthServer = contextData['oauthServer'] as String?; 356 + _oauthServer = contextData['server'] as String?; 410 357 final savedState = contextData['state'] as String?; 411 358 412 359 // Verify state parameter matches if present ··· 420 367 ); 421 368 // Clear invalid context 422 369 await StorageManager.instance.secure.remove( 423 - StorageKeys.oauthPendingContext, 370 + StorageKeys.pendingAuthContext, 424 371 ); 425 372 return LoginResult.failed( 426 373 'OAuth state verification failed. Please try again.', ··· 430 377 431 378 // Recreate OAuth client with the correct OAuth server 432 379 if (_oauthServer != null) { 433 - final metadata = await getClientMetadata(_clientMetadataUrl); 380 + final metadata = await _getCachedClientMetadata(); 434 381 _oauthClient = OAuthClient(metadata, service: _oauthServer!); 435 382 } 436 383 } ··· 441 388 ); 442 389 // Clear any partial context data 443 390 await StorageManager.instance.secure.remove( 444 - StorageKeys.oauthPendingContext, 391 + StorageKeys.pendingAuthContext, 445 392 ); 446 393 return LoginResult.failed( 447 394 'OAuth session was interrupted. ' ··· 492 439 493 440 // Clear pending context 494 441 await StorageManager.instance.secure.remove( 495 - StorageKeys.oauthPendingContext, 442 + StorageKeys.pendingAuthContext, 496 443 ); 497 444 _pendingContext = null; 498 445 ··· 565 512 566 513 // Try to recreate OAuth client if we have a session but no client 567 514 if (_oauthSession != null && _oauthServer != null) { 568 - final metadata = await getClientMetadata(_clientMetadataUrl); 515 + final metadata = await _getCachedClientMetadata(); 569 516 _oauthClient = OAuthClient(metadata, service: _oauthServer!); 570 517 _logger.d('OAuthClient recreated with service: $_oauthServer'); 571 518 } else {
+3 -2
lib/src/core/network/atproto/data/repositories/pref_repository_impl.dart
··· 16 16 final SparkLogger _logger = GetIt.instance<LogService>().getLogger( 17 17 'PrefRepository', 18 18 ); 19 + 19 20 @override 20 21 Future<Preferences> getPreferences() async { 21 - _logger.d('Getting user preferences'); 22 + _logger.d('Getting user preferences from server'); 22 23 return _client.executeWithRetry(() async { 23 24 if (!_client.authRepository.isAuthenticated) { 24 25 _logger.w('Not authenticated'); ··· 50 51 51 52 @override 52 53 Future<void> putPreferences(Preferences preferences) async { 53 - return _client.executeWithRetry(() async { 54 + await _client.executeWithRetry(() async { 54 55 if (!_client.authRepository.isAuthenticated) { 55 56 _logger.w('Not authenticated'); 56 57 throw Exception('Not authenticated');
+19 -7
lib/src/core/network/atproto/data/repositories/sprk_repository_impl.dart
··· 31 31 'SprkRepository', 32 32 ); 33 33 34 + // Cached repository instances 35 + ActorRepository? _actor; 36 + RepoRepository? _repo; 37 + GraphRepository? _graph; 38 + FeedRepository? _feed; 39 + StoryRepository? _story; 40 + LabelerRepository? _labeler; 41 + SoundRepository? _sound; 42 + 34 43 /// Get the authentication service 35 44 @override 36 45 AuthRepository get authRepository => _authRepository; ··· 59 68 /// only handles actual token expiration. 60 69 @override 61 70 Future<T> executeWithRetry<T>(Future<T> Function() apiCall) async { 71 + // Wait for auth initialization to complete before making API calls 72 + await _authRepository.initializationComplete; 73 + 62 74 try { 63 75 return await apiCall(); 64 76 } catch (e) { ··· 93 105 } 94 106 95 107 @override 96 - ActorRepository get actor => ActorRepositoryImpl(this); 108 + ActorRepository get actor => _actor ??= ActorRepositoryImpl(this); 97 109 98 110 @override 99 - RepoRepository get repo => RepoRepositoryImpl(this); 111 + RepoRepository get repo => _repo ??= RepoRepositoryImpl(this); 100 112 101 113 @override 102 - GraphRepository get graph => GraphRepositoryImpl(this); 114 + GraphRepository get graph => _graph ??= GraphRepositoryImpl(this); 103 115 104 116 @override 105 - FeedRepository get feed => FeedRepositoryImpl(this); 117 + FeedRepository get feed => _feed ??= FeedRepositoryImpl(this); 106 118 107 119 @override 108 - StoryRepository get story => StoryRepositoryImpl(this); 120 + StoryRepository get story => _story ??= StoryRepositoryImpl(this); 109 121 110 122 @override 111 - LabelerRepository get labeler => LabelerRepositoryImpl(this); 123 + LabelerRepository get labeler => _labeler ??= LabelerRepositoryImpl(this); 112 124 113 125 @override 114 - SoundRepository get sound => SoundRepositoryImpl(this); 126 + SoundRepository get sound => _sound ??= SoundRepositoryImpl(this); 115 127 }
+10 -36
lib/src/core/storage/cache/download_manager_impl.dart
··· 4 4 import 'package:get_it/get_it.dart'; 5 5 import 'package:pool/pool.dart'; 6 6 import 'package:spark/src/core/network/atproto/data/models/models.dart'; 7 - import 'package:spark/src/core/network/atproto/data/repositories/pref_repository.dart'; 8 7 import 'package:spark/src/core/storage/cache/download_manager_interface.dart'; 9 8 import 'package:spark/src/core/utils/logging/logging.dart'; 10 9 import 'package:spark/src/features/feed/providers/feed_state.dart'; ··· 14 13 _logger = GetIt.instance<LogService>().getLogger('DownloadManager'); 15 14 } 16 15 16 + /// Initializes the download manager with a default feed. 17 + /// The actual active feed will be set via [setActiveFeed] once the 18 + /// UserPreferencesProvider has loaded. 17 19 Future<void> init() async { 18 - try { 19 - final prefRepository = GetIt.instance<PrefRepository>(); 20 - final preferences = await prefRepository.getPreferences(); 21 - final feeds = preferences.savedFeeds ?? []; 22 - SavedFeed? activeSavedFeed; 23 - try { 24 - activeSavedFeed = feeds.firstWhere((feed) => feed.pinned); 25 - } catch (e) { 26 - if (feeds.isNotEmpty) { 27 - activeSavedFeed = feeds.first; 28 - } 29 - } 30 - if (activeSavedFeed == null) { 31 - _activeFeed = Feed( 32 - type: 'timeline', 33 - config: SavedFeed(type: 'timeline', value: 'following', pinned: true), 34 - ); 35 - } else { 36 - _activeFeed = Feed( 37 - type: activeSavedFeed.type, 38 - config: activeSavedFeed, 39 - ); 40 - } 41 - } catch (e) { 42 - // If not authenticated yet or preferences can't be loaded, use default 43 - // feed 44 - _logger.w( 45 - 'Could not load preferences during init ' 46 - '(user may not be authenticated yet): $e', 47 - ); 48 - _activeFeed = Feed( 49 - type: 'timeline', 50 - config: SavedFeed(type: 'timeline', value: 'following', pinned: true), 51 - ); 52 - } 20 + // Start with default feed - the actual active feed will be set 21 + // via setActiveFeed() once preferences are loaded from the provider 22 + _activeFeed = Feed( 23 + type: 'timeline', 24 + config: SavedFeed(type: 'timeline', value: 'following', pinned: true), 25 + ); 26 + _logger.d('DownloadManager initialized with default feed'); 53 27 } 54 28 55 29 @override
+3 -12
lib/src/core/storage/preferences/storage_constants.dart
··· 6 6 static const String dmAccessToken = 'dm_access_token'; 7 7 static const String dmRefreshToken = 'dm_refresh_token'; 8 8 9 - /// OAuth keys 10 - static const String oauthAccessToken = 'oauth_access_token'; 11 - static const String oauthRefreshToken = 'oauth_refresh_token'; 12 - static const String oauthPublicKey = 'oauth_public_key'; 13 - static const String oauthPrivateKey = 'oauth_private_key'; 14 - static const String oauthDpopNonce = 'oauth_dpop_nonce'; 15 - static const String oauthExpiresAt = 'oauth_expires_at'; 16 - static const String oauthPendingContext = 'oauth_pending_context'; 17 - static const String oauthDid = 'oauth_did'; 18 - static const String oauthHandle = 'oauth_handle'; 19 - static const String oauthPdsEndpoint = 'oauth_pds_endpoint'; 20 - static const String oauthServer = 'oauth_server'; 9 + /// Account (stores all auth data as single JSON object) 10 + static const String account = 'account'; 11 + static const String pendingAuthContext = 'pending_auth_context'; 21 12 22 13 static const String themeKey = 'app_theme_mode'; 23 14
+73 -67
lib/src/core/utils/label_utils.dart
··· 1 1 import 'package:atproto/com_atproto_label_defs.dart'; 2 - import 'package:get_it/get_it.dart'; 3 2 import 'package:spark/src/core/network/atproto/data/models/labeler_models.dart'; 4 - import 'package:spark/src/core/network/atproto/data/repositories/pref_repository.dart'; 3 + import 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; 5 4 5 + /// Utility class for working with labels. 6 + /// 7 + /// All methods that need preferences now take them as a parameter instead of 8 + /// fetching them. This ensures preferences are loaded once and passed down 9 + /// from the [UserPreferencesProvider]. 6 10 class LabelUtils { 7 - static Future<LabelPreference> _getLabelPreference(String value) async { 8 - final prefRepository = GetIt.instance<PrefRepository>(); 9 - final preferences = await prefRepository.getPreferences(); 11 + /// Gets a label preference from the given preferences. 12 + /// Returns null if not found instead of throwing. 13 + static LabelPreference? getLabelPreferenceFromPrefs( 14 + Preferences preferences, 15 + String value, 16 + ) { 10 17 final contentLabelPrefs = preferences.contentLabelPrefs ?? []; 11 - final contentLabelPref = contentLabelPrefs.firstWhere( 12 - (pref) => pref.label == value, 13 - orElse: () => throw Exception('Label preference not found'), 14 - ); 15 - return LabelPreference( 16 - value: contentLabelPref.label, 17 - blurs: _visibilityToBlurs(contentLabelPref.visibility), 18 - severity: _visibilityToSeverity(contentLabelPref.visibility), 19 - defaultSetting: _visibilityToSetting(contentLabelPref.visibility), 20 - setting: _visibilityToSetting(contentLabelPref.visibility), 21 - adultOnly: _isAdultOnlyLabel(value), 22 - ); 18 + try { 19 + final contentLabelPref = contentLabelPrefs.firstWhere( 20 + (pref) => pref.label == value, 21 + ); 22 + return LabelPreference( 23 + value: contentLabelPref.label, 24 + blurs: _visibilityToBlurs(contentLabelPref.visibility), 25 + severity: _visibilityToSeverity(contentLabelPref.visibility), 26 + defaultSetting: _visibilityToSetting(contentLabelPref.visibility), 27 + setting: _visibilityToSetting(contentLabelPref.visibility), 28 + adultOnly: _isAdultOnlyLabel(value), 29 + ); 30 + } catch (e) { 31 + return null; 32 + } 23 33 } 24 34 25 35 static Setting _visibilityToSetting(String visibility) { ··· 70 80 return adultOnlyLabels.contains(label); 71 81 } 72 82 73 - static Future<bool> shouldShowWarning(List<Label> labels) async { 83 + /// Checks if any label should show a warning. 84 + /// Takes preferences as a parameter instead of fetching. 85 + static bool shouldShowWarning(Preferences preferences, List<Label> labels) { 74 86 if (labels.isEmpty) return false; 75 87 76 88 for (final label in labels) { 77 - try { 78 - final preference = await _getLabelPreference(label.val); 79 - if (preference.severity == Severity.alert && 80 - preference.setting == Setting.warn) { 81 - return true; 82 - } 83 - } catch (e) { 84 - // If no preference found, continue checking other labels 85 - continue; 89 + final preference = getLabelPreferenceFromPrefs(preferences, label.val); 90 + if (preference != null && 91 + preference.severity == Severity.alert && 92 + preference.setting == Setting.warn) { 93 + return true; 86 94 } 87 95 } 88 96 89 97 return false; 90 98 } 91 99 92 - static Future<bool> shouldBlurContent(List<Label> labels) async { 100 + /// Checks if content should be blurred. 101 + /// Takes preferences as a parameter instead of fetching. 102 + static bool shouldBlurContent(Preferences preferences, List<Label> labels) { 93 103 if (labels.isEmpty) return false; 94 104 95 105 for (final label in labels) { 96 - try { 97 - final preference = await _getLabelPreference(label.val); 98 - if (preference.blurs == Blurs.content || 99 - preference.blurs == Blurs.media && 100 - preference.setting == Setting.warn) { 101 - return true; 102 - } 103 - } catch (e) { 104 - // If no preference found, continue checking other labels 105 - continue; 106 + final preference = getLabelPreferenceFromPrefs(preferences, label.val); 107 + if (preference != null && 108 + (preference.blurs == Blurs.content || 109 + (preference.blurs == Blurs.media && 110 + preference.setting == Setting.warn))) { 111 + return true; 106 112 } 107 113 } 108 114 109 115 return false; 110 116 } 111 117 112 - static Future<List<String>> getWarningLabels(List<Label> labels) async { 118 + /// Gets labels that should show warnings. 119 + /// Takes preferences as a parameter instead of fetching. 120 + static List<String> getWarningLabels( 121 + Preferences preferences, 122 + List<Label> labels, 123 + ) { 113 124 if (labels.isEmpty) return []; 114 125 115 126 final warningLabels = <String>[]; 116 127 117 128 for (final label in labels) { 118 - try { 119 - final preference = await _getLabelPreference(label.val); 120 - if (preference.severity == Severity.alert && 121 - preference.setting == Setting.warn) { 122 - warningLabels.add(label.val); 123 - } 124 - } catch (e) { 125 - // If no preference found, continue checking other labels 126 - continue; 129 + final preference = getLabelPreferenceFromPrefs(preferences, label.val); 130 + if (preference != null && 131 + preference.severity == Severity.alert && 132 + preference.setting == Setting.warn) { 133 + warningLabels.add(label.val); 127 134 } 128 135 } 129 136 130 137 return warningLabels; 131 138 } 132 139 133 - static Future<List<String>> getInformLabels(List<Label> labels) async { 140 + /// Gets labels that should show info. 141 + /// Takes preferences as a parameter instead of fetching. 142 + static List<String> getInformLabels( 143 + Preferences preferences, 144 + List<Label> labels, 145 + ) { 134 146 if (labels.isEmpty) return []; 135 147 136 148 final informLabels = <String>[]; 137 149 138 150 for (final label in labels) { 139 - try { 140 - final preference = await _getLabelPreference(label.val); 141 - if (preference.severity == Severity.inform && 142 - preference.setting == Setting.warn) { 143 - informLabels.add(label.val); 144 - } 145 - } catch (e) { 146 - // If no preference found, continue checking other labels 147 - continue; 151 + final preference = getLabelPreferenceFromPrefs(preferences, label.val); 152 + if (preference != null && 153 + preference.severity == Severity.inform && 154 + preference.setting == Setting.warn) { 155 + informLabels.add(label.val); 148 156 } 149 157 } 150 158 151 159 return informLabels; 152 160 } 153 161 154 - static Future<bool> shouldHideContent(List<Label> labels) async { 162 + /// Checks if content should be hidden. 163 + /// Takes preferences as a parameter instead of fetching. 164 + static bool shouldHideContent(Preferences preferences, List<Label> labels) { 155 165 if (labels.isEmpty) return false; 156 166 157 167 for (final label in labels) { 158 - try { 159 - final preference = await _getLabelPreference(label.val); 160 - if (preference.setting == Setting.hide || preference.adultOnly) { 161 - return true; 162 - } 163 - } catch (e) { 164 - // If no preference found, continue checking other labels 165 - continue; 168 + final preference = getLabelPreferenceFromPrefs(preferences, label.val); 169 + if (preference != null && 170 + (preference.setting == Setting.hide || preference.adultOnly)) { 171 + return true; 166 172 } 167 173 } 168 174
-28
lib/src/features/feed/providers/feed_provider.dart
··· 95 95 } 96 96 } 97 97 98 - bool _shouldUseBlueskyAPI() { 99 - // Determine if this feed should use Bluesky API for post hydration 100 - switch (_feed) { 101 - case Feed(type: 'timeline'): 102 - return false; 103 - case Feed(type: 'feed'): 104 - if (_feed.view != null) { 105 - return _feed.view!.uri.collection.toString() == 106 - 'app.bsky.feed.generator'; 107 - } 108 - case _: 109 - throw ArgumentError('Invalid feed type: $_feed'); 110 - } 111 - return false; 112 - } 113 - 114 98 Future<void> loadAndUpdateFirstLoad() async { 115 99 if (_isLoadingInProgress || state.loadingFirstLoad) { 116 100 _logger.w('Load already in progress, skipping duplicate call'); ··· 446 430 state = state.copyWith(active: active); 447 431 if (active) { 448 432 _downloadManager.setActiveFeed(_feed); 449 - } 450 - } 451 - 452 - Future<void> refreshPost(AtUri uri) async { 453 - try { 454 - final posts = await _feedRepository.getPosts([ 455 - uri, 456 - ], bluesky: _shouldUseBlueskyAPI()); 457 - if (posts.isEmpty) return; 458 - replacePost(posts.first); 459 - } catch (e, stackTrace) { 460 - _logger.e('Error refreshing post $uri: $e', stackTrace: stackTrace); 461 433 } 462 434 } 463 435
+6 -1
lib/src/features/feed/ui/pages/feed_page.dart
··· 56 56 57 57 @override 58 58 void dispose() { 59 - _actionControllerNotifier?.clearController(); 59 + // Delay clearing the controller to avoid modifying provider state 60 + // during widget tree finalization 61 + final notifier = _actionControllerNotifier; 62 + if (notifier != null) { 63 + Future(() => notifier.clearController()); 64 + } 60 65 pageController.dispose(); 61 66 super.dispose(); 62 67 }
+13 -5
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 16 16 import 'package:spark/src/features/feed/ui/widgets/images/image_carousel.dart'; 17 17 import 'package:spark/src/features/feed/ui/widgets/post/post_overlay.dart'; 18 18 import 'package:spark/src/features/feed/ui/widgets/videos/video_player.dart'; 19 + import 'package:spark/src/features/settings/providers/preferences_provider.dart'; 19 20 20 21 @RoutePage() 21 22 class StandalonePostPage extends ConsumerStatefulWidget { ··· 73 74 throw Exception('Failed to load post after $maxRetries attempts'); 74 75 } 75 76 76 - Future<void> _checkContentWarning(PostView postData) async { 77 + void _checkContentWarning(PostView postData) { 77 78 final labels = postData.labels ?? []; 79 + final preferences = ref.read(userPreferencesProvider).asData?.value; 78 80 79 - if (labels.isNotEmpty) { 80 - final shouldShowWarning = await LabelUtils.shouldShowWarning(labels); 81 - final shouldBlurContent = await LabelUtils.shouldBlurContent(labels); 81 + if (labels.isNotEmpty && preferences != null) { 82 + final shouldShowWarning = LabelUtils.shouldShowWarning( 83 + preferences, 84 + labels, 85 + ); 86 + final shouldBlurContent = LabelUtils.shouldBlurContent( 87 + preferences, 88 + labels, 89 + ); 82 90 if (shouldShowWarning) { 83 - final warningLabels = await LabelUtils.getWarningLabels(labels); 91 + final warningLabels = LabelUtils.getWarningLabels(preferences, labels); 84 92 setState(() { 85 93 _showWarningOverlay = true; 86 94 _warningLabels = warningLabels;
+31 -32
lib/src/features/feed/ui/widgets/post/feed_post_widget.dart
··· 1 1 import 'package:atproto/com_atproto_label_defs.dart'; 2 - import 'package:atproto/core.dart'; 3 2 import 'package:auto_route/auto_route.dart'; 4 3 import 'package:flutter/material.dart'; 5 4 import 'package:flutter/services.dart'; ··· 12 11 import 'package:spark/src/core/utils/label_utils.dart'; 13 12 import 'package:spark/src/features/feed/providers/feed_provider.dart'; 14 13 import 'package:spark/src/features/feed/providers/like_post.dart'; 15 - import 'package:spark/src/features/feed/providers/post_updates.dart'; 16 14 import 'package:spark/src/features/feed/ui/widgets/images/image_carousel.dart'; 17 15 import 'package:spark/src/features/feed/ui/widgets/post/post_overlay.dart'; 18 16 import 'package:spark/src/features/feed/ui/widgets/videos/video_player.dart'; 19 17 import 'package:spark/src/features/home/providers/navigation_provider.dart'; 18 + import 'package:spark/src/features/settings/providers/preferences_provider.dart'; 20 19 21 20 class FeedPostWidget extends ConsumerStatefulWidget { 22 21 const FeedPostWidget({ ··· 35 34 class _FeedPostWidgetState extends ConsumerState<FeedPostWidget> { 36 35 Future<PostView>? _postFuture; 37 36 String? _lastPostUri; 38 - int? _lastUpdateCount; 39 37 final GlobalKey<PostVideoPlayerState> _videoPlayerKey = 40 38 GlobalKey<PostVideoPlayerState>(); 41 39 bool _isAnimatingHeart = false; ··· 113 111 } 114 112 } 115 113 116 - Future<void> _checkContentWarning(String postUri) async { 114 + void _checkContentWarning(String postUri) { 117 115 final feedState = ref.read(feedProvider(widget.feed)); 116 + final preferences = ref.read(userPreferencesProvider).asData?.value; 117 + 118 118 if (widget.index < feedState.loadedPosts.length) { 119 119 final post = feedState.loadedPosts[widget.index]; 120 120 if (post.uri.toString() != postUri) { ··· 124 124 125 125 if (extraInfo != null && 126 126 extraInfo.postLabels.isNotEmpty && 127 - !_userDismissedWarning) { 128 - final shouldShowWarning = await LabelUtils.shouldShowWarning( 127 + !_userDismissedWarning && 128 + preferences != null) { 129 + final shouldShowWarning = LabelUtils.shouldShowWarning( 130 + preferences, 129 131 extraInfo.postLabels, 130 132 ); 131 133 if (shouldShowWarning) { 132 - final warningLabels = await LabelUtils.getWarningLabels( 134 + final warningLabels = LabelUtils.getWarningLabels( 135 + preferences, 133 136 extraInfo.postLabels, 134 137 ); 135 138 setState(() { ··· 164 167 final post = feedState.loadedPosts[widget.index]; 165 168 final currentUri = post.uri.toString(); 166 169 167 - // Watch for post updates to trigger reload 168 - final updateCount = ref.watch(postUpdateProvider(currentUri)); 169 - 170 - if (_lastPostUri != currentUri || _lastUpdateCount != updateCount) { 171 - _lastUpdateCount = updateCount; 170 + // Update local state when URI changes (scrolling to different post) 171 + // Posts are already hydrated from getFeed - no need to call getPosts 172 + if (_lastPostUri != currentUri) { 173 + _lastPostUri = currentUri; 172 174 WidgetsBinding.instance.addPostFrameCallback((_) { 173 175 if (mounted) { 174 - ref 175 - .read(feedProvider(widget.feed).notifier) 176 - .refreshPost(AtUri.parse(currentUri)); 177 176 setState(_loadPost); 178 177 _checkContentWarning(currentUri); 179 178 } ··· 251 250 index: widget.index, 252 251 thumbnail: postData.thumbnailUrl, 253 252 ), 254 - MediaViewImages() || MediaViewBskyImages() => 255 - ImageCarousel( 256 - imageUrls: postData.imageUrls, 257 - hasKnownInteractions: currentPost.viewer 258 - ?.knownInteractions != 259 - null && 260 - currentPost.viewer!.knownInteractions!.isNotEmpty, 261 - ), 253 + MediaViewImages() || 254 + MediaViewBskyImages() => ImageCarousel( 255 + imageUrls: postData.imageUrls, 256 + hasKnownInteractions: 257 + currentPost.viewer?.knownInteractions != null && 258 + currentPost.viewer!.knownInteractions!.isNotEmpty, 259 + ), 262 260 MediaViewBskyRecordWithMedia(:final media) => 263 261 switch (media) { 264 262 MediaViewVideo() => PostVideoPlayer( ··· 275 273 index: widget.index, 276 274 thumbnail: postData.thumbnailUrl, 277 275 ), 278 - MediaViewImages() || MediaViewBskyImages() => 279 - ImageCarousel( 280 - imageUrls: postData.imageUrls, 281 - hasKnownInteractions: currentPost.viewer 282 - ?.knownInteractions != 283 - null && 284 - currentPost.viewer!.knownInteractions! 285 - .isNotEmpty, 286 - ), 276 + MediaViewImages() || 277 + MediaViewBskyImages() => ImageCarousel( 278 + imageUrls: postData.imageUrls, 279 + hasKnownInteractions: 280 + currentPost.viewer?.knownInteractions != null && 281 + currentPost 282 + .viewer! 283 + .knownInteractions! 284 + .isNotEmpty, 285 + ), 287 286 _ => const DecoratedBox( 288 287 decoration: BoxDecoration(color: AppColors.black), 289 288 ),
+10 -6
lib/src/features/feed/ui/widgets/post/post_overlay.dart
··· 1 1 import 'package:atproto/com_atproto_label_defs.dart'; 2 2 import 'package:flutter/material.dart'; 3 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 4 import 'package:spark/src/core/design_system/components/molecules/known_interactions_bar.dart'; 4 5 import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 5 6 import 'package:spark/src/core/utils/label_utils.dart'; 6 7 import 'package:spark/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart'; 7 8 import 'package:spark/src/features/feed/ui/widgets/post/info_bar.dart'; 9 + import 'package:spark/src/features/settings/providers/preferences_provider.dart'; 8 10 9 - class PostOverlay extends StatelessWidget { 11 + class PostOverlay extends ConsumerWidget { 10 12 const PostOverlay({ 11 13 required this.post, 12 14 super.key, ··· 30 32 final bool showBlockOption; 31 33 32 34 @override 33 - Widget build(BuildContext context) { 35 + Widget build(BuildContext context, WidgetRef ref) { 34 36 final bottomPadding = MediaQuery.of(context).padding.bottom; 37 + final preferences = ref.watch(userPreferencesProvider).asData?.value; 35 38 36 39 return Stack( 37 40 children: [ ··· 85 88 ), 86 89 ), 87 90 // Author info and caption 88 - FutureBuilder<List<String>>( 89 - future: LabelUtils.getInformLabels(labels), 90 - builder: (context, snapshot) { 91 - final informLabels = snapshot.data ?? []; 91 + Builder( 92 + builder: (context) { 93 + final informLabels = preferences != null 94 + ? LabelUtils.getInformLabels(preferences, labels) 95 + : <String>[]; 92 96 return InfoBar( 93 97 username: post.author.handle, 94 98 displayName:
+49 -37
lib/src/features/profile/providers/blocks_provider.dart
··· 1 - import 'package:bluesky/bluesky.dart' as bsky; 2 1 import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 - import 'package:spark/src/core/auth/data/repositories/auth_repository.dart'; 4 2 import 'package:spark/src/core/di/service_locator.dart'; 5 3 import 'package:spark/src/core/network/atproto/atproto.dart'; 6 4 import 'package:spark/src/features/profile/providers/user_list_provider.dart'; ··· 10 8 @Riverpod(keepAlive: true) 11 9 class Blocks extends _$Blocks { 12 10 final GraphRepository _graphRepository = sl<GraphRepository>(); 13 - final AuthRepository _authRepository = sl<AuthRepository>(); 11 + final ActorRepository _actorRepository = sl<ActorRepository>(); 14 12 15 13 @override 16 14 Future<PaginatedUserList> build({required String did}) async { ··· 18 16 final profiles = response.blocks.toList(); 19 17 final cursor = response.cursor; 20 18 21 - await _fetchAndMergeProfilesFromBsky(profiles); 19 + await _fetchAndMergeProfiles(profiles); 22 20 23 21 // remove profiles with unknown.invalid handle 24 22 profiles.removeWhere( ··· 29 27 return PaginatedUserList(profiles: profiles, cursor: cursor); 30 28 } 31 29 32 - Future<void> _fetchAndMergeProfilesFromBsky( 30 + /// Fetch missing profile data using ActorRepository (Spark-first with 31 + /// Bluesky fallback) 32 + /// Only fetches profiles that are actually incomplete (missing key fields) 33 + Future<void> _fetchAndMergeProfiles( 33 34 List<ProfileView> profiles, 34 35 ) async { 36 + // Check if profile is incomplete - need to check multiple fields 37 + // A profile is incomplete if it's missing displayName, description, or avatar 38 + // AND has a valid handle (if handle is missing, it's likely a deleted account) 35 39 final didsToFetch = profiles 36 - .where((profile) => profile.displayName == null) 40 + .where((profile) { 41 + // Profile is incomplete if it has a valid handle but is missing key fields 42 + final hasValidHandle = profile.handle.isNotEmpty && 43 + profile.handle != 'unknown.invalid'; 44 + final isIncomplete = hasValidHandle && 45 + (profile.displayName == null || 46 + profile.description == null || 47 + profile.avatar == null); 48 + return isIncomplete; 49 + }) 37 50 .map((profile) => profile.did) 38 51 .toList(); 39 52 40 - if (didsToFetch.isNotEmpty) { 41 - final atproto = _authRepository.atproto; 42 - if (atproto != null && atproto.oAuthSession != null) { 43 - final bskyClient = bsky.Bluesky.fromOAuthSession(atproto.oAuthSession!); 44 - final fetchedProfiles = <dynamic>[]; 53 + if (didsToFetch.isEmpty) return; 45 54 46 - for (var i = 0; i < didsToFetch.length; i += 25) { 47 - final batch = didsToFetch.sublist( 48 - i, 49 - i + 25 > didsToFetch.length ? didsToFetch.length : i + 25, 50 - ); 51 - final profilesResponse = await bskyClient.actor.getProfiles( 52 - actors: batch, 53 - ); 54 - fetchedProfiles.addAll(profilesResponse.data.profiles); 55 - } 56 - final profilesMap = {for (final p in fetchedProfiles) p.did: p}; 55 + // Use ActorRepository which has proper Spark-first, Bluesky-fallback logic 56 + final fetchedProfiles = <ProfileViewDetailed>[]; 57 57 58 - for (var i = 0; i < profiles.length; i++) { 59 - final profile = profiles[i]; 60 - if (profilesMap.containsKey(profile.did)) { 61 - final fetchedProfile = profilesMap[profile.did]; 62 - profiles[i] = profile.copyWith( 63 - displayName: fetchedProfile.displayName as String?, 64 - description: fetchedProfile.description as String?, 65 - handle: fetchedProfile.handle as String, 66 - avatar: fetchedProfile.avatar != null 67 - ? Uri.parse(fetchedProfile.avatar as String) 68 - : null, 69 - ); 70 - } 71 - } 58 + for (var i = 0; i < didsToFetch.length; i += 25) { 59 + final batch = didsToFetch.sublist( 60 + i, 61 + i + 25 > didsToFetch.length ? didsToFetch.length : i + 25, 62 + ); 63 + try { 64 + final batchProfiles = await _actorRepository.getProfiles(batch); 65 + fetchedProfiles.addAll(batchProfiles); 66 + } catch (e) { 67 + // If batch fails, continue with other batches 68 + continue; 69 + } 70 + } 71 + 72 + final profilesMap = {for (final p in fetchedProfiles) p.did: p}; 73 + 74 + for (var i = 0; i < profiles.length; i++) { 75 + final profile = profiles[i]; 76 + if (profilesMap.containsKey(profile.did)) { 77 + final fetchedProfile = profilesMap[profile.did]!; 78 + profiles[i] = profile.copyWith( 79 + displayName: fetchedProfile.displayName, 80 + description: fetchedProfile.description, 81 + handle: fetchedProfile.handle, 82 + avatar: fetchedProfile.avatar, 83 + ); 72 84 } 73 85 } 74 86 } ··· 102 114 final newProfiles = response.blocks.toList(); 103 115 final newCursor = response.cursor; 104 116 105 - await _fetchAndMergeProfilesFromBsky(newProfiles); 117 + await _fetchAndMergeProfiles(newProfiles); 106 118 107 119 state = AsyncValue.data( 108 120 state.value!.copyWith(
+1 -94
lib/src/features/profile/providers/profile_feed_provider.dart
··· 1 1 import 'dart:collection'; 2 2 3 - import 'package:atproto/com_atproto_label_defs.dart'; 4 3 import 'package:atproto_core/atproto_core.dart'; 5 4 import 'package:get_it/get_it.dart'; 6 5 import 'package:riverpod_annotation/riverpod_annotation.dart'; ··· 10 9 import 'package:spark/src/core/utils/logging/log_service.dart'; 11 10 import 'package:spark/src/core/utils/logging/logger.dart'; 12 11 import 'package:spark/src/features/profile/providers/profile_feed_state.dart'; 13 - import 'package:spark/src/features/settings/providers/settings_provider.dart'; 14 12 15 13 part 'profile_feed_provider.g.dart'; 16 14 ··· 92 90 newPosts.sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); 93 91 allPosts.addAll(newPosts.map((post) => post.uri)); 94 92 95 - // Get additional labels from followed labelers for new posts 96 - if (newPosts.isNotEmpty) { 97 - try { 98 - final settings = ref.read(settingsProvider.notifier); 99 - final followedLabelers = await settings.getLabelers(); 100 - final newPostUris = newPosts.map((post) => post.uri).toList(); 101 - final (cursor: _, labels: additionalLabels) = await _feedRepository 102 - .getLabels(newPostUris, sources: followedLabelers); 103 - // Add the additional labels to the posts 104 - for (final label in additionalLabels) { 105 - final uri = AtUri.parse(label.uri); 106 - final post = postViews[uri]; 107 - if (post != null) { 108 - final existingLabels = 109 - post.labels != null ? List<Label>.from(post.labels!) : <Label>[] 110 - ..add(label); 111 - postViews[uri] = post.copyWith(labels: existingLabels); 112 - } 113 - } 114 - } catch (e) { 115 - _logger.e('Error fetching additional labels: $e'); 116 - } 117 - } 118 - 119 - // Client-side components decide whether to show videos/images/all. 120 - // Here we only apply label-based filtering and return all posts. 121 - final filteredPosts = await _filterHiddenPosts(allPosts, postViews); 122 - 123 93 // End of network when: 124 94 // 1. API returns null cursor (no more pages) 125 95 // 2. API returns fewer posts than requested (last page) ··· 131 101 currentState.allPosts.length == allPosts.length); 132 102 133 103 return ProfileFeedState( 134 - loadedPosts: filteredPosts, 104 + loadedPosts: allPosts, 135 105 allPosts: allPosts, 136 106 isEndOfNetwork: isEndOfNetwork, 137 107 cursor: result.cursor, ··· 205 175 _logger.e('Error refreshing posts: $e'); 206 176 state = AsyncValue.error(e, StackTrace.current); 207 177 } 208 - } 209 - 210 - /// Checks if a post should be hidden based on its labels and user preferences 211 - Future<bool> _shouldHidePost(AtUri uri, List<Label> postLabels) async { 212 - final settings = ref.read(settingsProvider.notifier); 213 - for (final label in postLabels) { 214 - try { 215 - final labelPreference = await settings.getLabelPreference(label.val); 216 - if (labelPreference.setting == Setting.hide || 217 - labelPreference.adultOnly) { 218 - return true; 219 - } 220 - } catch (e) { 221 - // Label preference not found, continue checking other labels 222 - continue; 223 - } 224 - } 225 - return false; 226 - } 227 - 228 - /// Filters URIs based on label preferences 229 - Future<List<AtUri>> _filterHiddenPosts( 230 - List<AtUri> uris, 231 - Map<AtUri, PostView> postViews, 232 - ) async { 233 - final filteredUris = <AtUri>[]; 234 - 235 - for (final uri in uris) { 236 - final postView = postViews[uri]; 237 - if (postView != null) { 238 - // Collect all labels for this post 239 - final postLabels = <Label>[]; 240 - 241 - // Add labels from the post itself 242 - if (postView.labels != null) { 243 - postLabels.addAll(postView.labels!); 244 - } 245 - 246 - // Add self labels from the post record 247 - if (postView.record.selfLabels != null) { 248 - for (final selfLabel in postView.record.selfLabels!) { 249 - postLabels.add( 250 - Label( 251 - uri: postView.uri.toString(), 252 - val: selfLabel.val, 253 - src: postView.uri.toString(), 254 - cts: postView.indexedAt, 255 - ), 256 - ); 257 - } 258 - } 259 - 260 - final shouldHide = await _shouldHidePost(uri, postLabels); 261 - if (!shouldHide) { 262 - filteredUris.add(uri); 263 - } 264 - } else { 265 - // No post view means no labels, so include the post 266 - filteredUris.add(uri); 267 - } 268 - } 269 - 270 - return filteredUris; 271 178 } 272 179 273 180 Future<void> deletePost(AtUri postUri) async {
+2 -20
lib/src/features/profile/providers/profile_provider.dart
··· 66 66 'Profile loaded successfully for $effectiveDid: ${profile.handle}', 67 67 ); 68 68 69 - final isEarlySupporter = await actorRepository.isEarlySupporter( 70 - effectiveDid, 71 - ); 72 - logger.d('Early supporter status for $effectiveDid: $isEarlySupporter'); 73 - 74 69 state = AsyncData( 75 70 currentState.copyWith( 76 71 profile: profile, 77 - isEarlySupporter: isEarlySupporter, 78 72 showAuthPrompt: false, 79 73 currentViewDid: effectiveDid, 80 74 ), ··· 207 201 final refreshedProfile = await actorRepository.getProfile( 208 202 profile.did, 209 203 ); 210 - final isEarlySupporter = await actorRepository.isEarlySupporter( 211 - profile.did, 212 - ); 213 204 214 205 // Only update if state hasn't changed (user hasn't navigated away) 215 206 final currentState = state.asData?.value; 216 207 if (currentState?.profile?.did == profile.did) { 217 208 state = AsyncData( 218 - currentState!.copyWith( 219 - profile: refreshedProfile, 220 - isEarlySupporter: isEarlySupporter, 221 - ), 209 + currentState!.copyWith(profile: refreshedProfile), 222 210 ); 223 211 } 224 212 } catch (e) { ··· 303 291 final refreshedProfile = await actorRepository.getProfile( 304 292 profile.did, 305 293 ); 306 - final isEarlySupporter = await actorRepository.isEarlySupporter( 307 - profile.did, 308 - ); 309 294 310 295 // Only update if state hasn't changed (user hasn't navigated away) 311 296 final currentState = state.asData?.value; 312 297 if (currentState?.profile?.did == profile.did) { 313 298 state = AsyncData( 314 - currentState!.copyWith( 315 - profile: refreshedProfile, 316 - isEarlySupporter: isEarlySupporter, 317 - ), 299 + currentState!.copyWith(profile: refreshedProfile), 318 300 ); 319 301 } 320 302 } catch (e) {
+1 -94
lib/src/features/profile/providers/profile_reposts_provider.dart
··· 1 1 import 'dart:collection'; 2 2 3 - import 'package:atproto/com_atproto_label_defs.dart'; 4 3 import 'package:atproto_core/atproto_core.dart'; 5 4 import 'package:get_it/get_it.dart'; 6 5 import 'package:riverpod_annotation/riverpod_annotation.dart'; ··· 10 9 import 'package:spark/src/core/utils/logging/log_service.dart'; 11 10 import 'package:spark/src/core/utils/logging/logger.dart'; 12 11 import 'package:spark/src/features/profile/providers/profile_feed_state.dart'; 13 - import 'package:spark/src/features/settings/providers/settings_provider.dart'; 14 12 15 13 part 'profile_reposts_provider.g.dart'; 16 14 ··· 88 86 newPosts.sort((a, b) => b.indexedAt.compareTo(a.indexedAt)); 89 87 allPosts.addAll(newPosts.map((post) => post.uri)); 90 88 91 - // Get additional labels from followed labelers for new posts 92 - if (newPosts.isNotEmpty) { 93 - try { 94 - final settings = ref.read(settingsProvider.notifier); 95 - final followedLabelers = await settings.getLabelers(); 96 - final newPostUris = newPosts.map((post) => post.uri).toList(); 97 - final (cursor: _, labels: additionalLabels) = await _feedRepository 98 - .getLabels(newPostUris, sources: followedLabelers); 99 - // Add the additional labels to the posts 100 - for (final label in additionalLabels) { 101 - final uri = AtUri.parse(label.uri); 102 - final post = postViews[uri]; 103 - if (post != null) { 104 - final existingLabels = 105 - post.labels != null ? List<Label>.from(post.labels!) : <Label>[] 106 - ..add(label); 107 - postViews[uri] = post.copyWith(labels: existingLabels); 108 - } 109 - } 110 - } catch (e) { 111 - _logger.e('Error fetching additional labels: $e'); 112 - } 113 - } 114 - 115 - // Client-side components decide whether to show videos/images/all. 116 - // Here we only apply label-based filtering and return all posts. 117 - final filteredPosts = await _filterHiddenPosts(allPosts, postViews); 118 - 119 89 // End of network when: 120 90 // 1. API returns null cursor (no more pages) 121 91 // 2. API returns fewer posts than requested (last page) ··· 127 97 currentState.allPosts.length == allPosts.length); 128 98 129 99 return ProfileFeedState( 130 - loadedPosts: filteredPosts, 100 + loadedPosts: allPosts, 131 101 allPosts: allPosts, 132 102 isEndOfNetwork: isEndOfNetwork, 133 103 cursor: result.cursor, ··· 197 167 _logger.e('Error refreshing reposts: $e'); 198 168 state = AsyncValue.error(e, StackTrace.current); 199 169 } 200 - } 201 - 202 - /// Checks if a post should be hidden based on its labels and user preferences 203 - Future<bool> _shouldHidePost(AtUri uri, List<Label> postLabels) async { 204 - final settings = ref.read(settingsProvider.notifier); 205 - for (final label in postLabels) { 206 - try { 207 - final labelPreference = await settings.getLabelPreference(label.val); 208 - if (labelPreference.setting == Setting.hide || 209 - labelPreference.adultOnly) { 210 - return true; 211 - } 212 - } catch (e) { 213 - // Label preference not found, continue checking other labels 214 - continue; 215 - } 216 - } 217 - return false; 218 - } 219 - 220 - /// Filters URIs based on label preferences 221 - Future<List<AtUri>> _filterHiddenPosts( 222 - List<AtUri> uris, 223 - Map<AtUri, PostView> postViews, 224 - ) async { 225 - final filteredUris = <AtUri>[]; 226 - 227 - for (final uri in uris) { 228 - final postView = postViews[uri]; 229 - if (postView != null) { 230 - // Collect all labels for this post 231 - final postLabels = <Label>[]; 232 - 233 - // Add labels from the post itself 234 - if (postView.labels != null) { 235 - postLabels.addAll(postView.labels!); 236 - } 237 - 238 - // Add self labels from the post record 239 - if (postView.record.selfLabels != null) { 240 - for (final selfLabel in postView.record.selfLabels!) { 241 - postLabels.add( 242 - Label( 243 - uri: postView.uri.toString(), 244 - val: selfLabel.val, 245 - src: postView.uri.toString(), 246 - cts: postView.indexedAt, 247 - ), 248 - ); 249 - } 250 - } 251 - 252 - final shouldHide = await _shouldHidePost(uri, postLabels); 253 - if (!shouldHide) { 254 - filteredUris.add(uri); 255 - } 256 - } else { 257 - // No post view means no labels, so include the post 258 - filteredUris.add(uri); 259 - } 260 - } 261 - 262 - return filteredUris; 263 170 } 264 171 }
-1
lib/src/features/profile/providers/profile_state.dart
··· 7 7 abstract class ProfileState with _$ProfileState { 8 8 const factory ProfileState({ 9 9 ProfileViewDetailed? profile, 10 - @Default(false) bool isEarlySupporter, 11 10 @Default(false) bool showAuthPrompt, 12 11 String? 13 12 currentViewDid, // DID being viewed or null for current user's profile
+49 -35
lib/src/features/profile/providers/user_list_provider.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 - import 'package:bluesky/bluesky.dart' as bsky; 3 2 import 'package:riverpod_annotation/riverpod_annotation.dart'; 4 3 import 'package:spark/src/core/auth/data/repositories/auth_repository.dart'; 5 4 import 'package:spark/src/core/di/service_locator.dart'; ··· 41 40 @Riverpod(keepAlive: true) 42 41 class UserList extends _$UserList { 43 42 final GraphRepository _graphRepository = sl<GraphRepository>(); 43 + final ActorRepository _actorRepository = sl<ActorRepository>(); 44 44 final AuthRepository _authRepository = sl<AuthRepository>(); 45 45 46 46 bool isCurrentUser(String did) { ··· 67 67 cursor = response.cursor; 68 68 } 69 69 70 - await _fetchAndMergeProfilesFromBsky(profiles); 70 + await _fetchAndMergeProfiles(profiles); 71 71 72 72 // remove profiles with unknown.invalid handle 73 73 profiles.removeWhere( ··· 78 78 return PaginatedUserList(profiles: profiles, cursor: cursor); 79 79 } 80 80 81 - Future<void> _fetchAndMergeProfilesFromBsky( 81 + /// Fetch missing profile data using ActorRepository (Spark-first with 82 + /// Bluesky fallback) 83 + /// Only fetches profiles that are actually incomplete (missing key fields) 84 + Future<void> _fetchAndMergeProfiles( 82 85 List<ProfileView> profiles, 83 86 ) async { 87 + // Check if profile is incomplete - need to check multiple fields 88 + // A profile is incomplete if it's missing displayName, description, or avatar 89 + // AND has a valid handle (if handle is missing, it's likely a deleted account) 84 90 final didsToFetch = profiles 85 - .where((profile) => profile.displayName == null) 91 + .where((profile) { 92 + // Profile is incomplete if it has a valid handle but is missing key fields 93 + final hasValidHandle = profile.handle.isNotEmpty && 94 + profile.handle != 'unknown.invalid'; 95 + final isIncomplete = hasValidHandle && 96 + (profile.displayName == null || 97 + profile.description == null || 98 + profile.avatar == null); 99 + return isIncomplete; 100 + }) 86 101 .map((profile) => profile.did) 87 102 .toList(); 88 103 89 - if (didsToFetch.isNotEmpty) { 90 - final atproto = _authRepository.atproto; 91 - if (atproto != null && atproto.oAuthSession != null) { 92 - final bskyClient = bsky.Bluesky.fromOAuthSession(atproto.oAuthSession!); 93 - final fetchedProfiles = <dynamic>[]; 104 + if (didsToFetch.isEmpty) return; 94 105 95 - for (var i = 0; i < didsToFetch.length; i += 25) { 96 - final batch = didsToFetch.sublist( 97 - i, 98 - i + 25 > didsToFetch.length ? didsToFetch.length : i + 25, 99 - ); 100 - final profilesResponse = await bskyClient.actor.getProfiles( 101 - actors: batch, 102 - ); 103 - fetchedProfiles.addAll(profilesResponse.data.profiles); 104 - } 105 - final profilesMap = {for (final p in fetchedProfiles) p.did: p}; 106 + // Use ActorRepository which has proper Spark-first, Bluesky-fallback logic 107 + final fetchedProfiles = <ProfileViewDetailed>[]; 106 108 107 - for (var i = 0; i < profiles.length; i++) { 108 - final profile = profiles[i]; 109 - if (profilesMap.containsKey(profile.did)) { 110 - final fetchedProfile = profilesMap[profile.did]; 111 - profiles[i] = profile.copyWith( 112 - displayName: fetchedProfile.displayName as String?, 113 - description: fetchedProfile.description as String?, 114 - handle: fetchedProfile.handle as String, 115 - avatar: fetchedProfile.avatar != null 116 - ? Uri.parse(fetchedProfile.avatar as String) 117 - : null, 118 - ); 119 - } 120 - } 109 + for (var i = 0; i < didsToFetch.length; i += 25) { 110 + final batch = didsToFetch.sublist( 111 + i, 112 + i + 25 > didsToFetch.length ? didsToFetch.length : i + 25, 113 + ); 114 + try { 115 + final batchProfiles = await _actorRepository.getProfiles(batch); 116 + fetchedProfiles.addAll(batchProfiles); 117 + } catch (e) { 118 + // If batch fails, continue with other batches 119 + continue; 120 + } 121 + } 122 + 123 + final profilesMap = {for (final p in fetchedProfiles) p.did: p}; 124 + 125 + for (var i = 0; i < profiles.length; i++) { 126 + final profile = profiles[i]; 127 + if (profilesMap.containsKey(profile.did)) { 128 + final fetchedProfile = profilesMap[profile.did]!; 129 + profiles[i] = profile.copyWith( 130 + displayName: fetchedProfile.displayName, 131 + description: fetchedProfile.description, 132 + handle: fetchedProfile.handle, 133 + avatar: fetchedProfile.avatar, 134 + ); 121 135 } 122 136 } 123 137 } ··· 189 203 newCursor = response.cursor; 190 204 } 191 205 192 - await _fetchAndMergeProfilesFromBsky(newProfiles); 206 + await _fetchAndMergeProfiles(newProfiles); 193 207 194 208 state = AsyncValue.data( 195 209 state.value!.copyWith(
+10 -25
lib/src/features/profile/ui/pages/profile_page.dart
··· 25 25 import 'package:spark/src/features/profile/providers/profile_provider.dart'; 26 26 import 'package:spark/src/features/profile/providers/profile_reposts_provider.dart'; 27 27 import 'package:spark/src/features/profile/ui/pages/user_list_page.dart'; 28 - import 'package:spark/src/features/profile/ui/widgets/early_supporter_sheet.dart'; 29 28 import 'package:spark/src/features/profile/ui/widgets/profile_grid_tab.dart'; 30 29 import 'package:spark/src/features/profile/ui/widgets/profile_reposts_tab.dart'; 31 30 import 'package:spark/src/features/profile/ui/widgets/profile_tab_base.dart'; ··· 124 123 return tabWidget.buildSlivers(context, ref); 125 124 } 126 125 127 - void _showEarlySupporterInfo(BuildContext context) { 128 - showModalBottomSheet( 129 - context: context, 130 - isScrollControlled: true, 131 - backgroundColor: Colors.transparent, 132 - builder: (context) => const SafeArea( 133 - child: Padding( 134 - padding: EdgeInsets.only(top: 20), 135 - child: EarlySupporterSheet(), 136 - ), 137 - ), 138 - ); 139 - } 140 - 141 126 Future<void> _handleUsernameTap(String username) async { 142 127 try { 143 128 final cleanUsername = username.startsWith('@') ··· 181 166 182 167 // Tab 0 is the default profile content (built directly, not a route) 183 168 // Tabs 1+ are subpages (route pages) 184 - // Initialize all tabs to cache their widgets 169 + // Only initialize the active tab widget 185 170 final profileUri = AtUri.parse('at://${widget.did}'); 186 - _getTabWidget(0); 187 - _getTabWidget(1); 171 + _getTabWidget(_activeTabIndex); 188 172 189 - // Watch all tab providers to keep their state alive even when not visible 190 - // This ensures tabs don't reload when switching between them 191 - ref.watch(profileFeedProvider(profileUri, false)); 192 - final actor = profileUri.hostname; 193 - ref.watch(profileRepostsProvider(actor)); 173 + // Only watch the active tab's provider - lazy load other tabs 174 + // This reduces initial load time by not fetching data for hidden tabs 175 + if (_activeTabIndex == 0) { 176 + ref.watch(profileFeedProvider(profileUri, false)); 177 + } else if (_activeTabIndex == 1) { 178 + final actor = profileUri.hostname; 179 + ref.watch(profileRepostsProvider(actor)); 180 + } 194 181 195 182 // Build slivers for the active tab using cached widget 196 183 final contentSlivers = _buildSliversForTab( ··· 254 241 isCurrentUser: isCurrentUser, 255 242 isFollowing: profile.viewer?.following != null, 256 243 isBlocking: isBlocking(profile.viewer), 257 - isEarlySupporter: state.isEarlySupporter, 258 244 onAvatarTap: (profile.stories?.isNotEmpty ?? false) 259 245 ? () => _openStoriesViewer(profile) 260 246 : null, ··· 296 282 }, 297 283 onShareTap: () => 298 284 _logger.i('Share profile tapped for ${profile.did}'), 299 - onEarlySupporterTap: () => _showEarlySupporterInfo(context), 300 285 onMentionTap: _handleUsernameTap, 301 286 onAddStoryTap: isCurrentUser ? () => _handleAddStory(context) : null, 302 287 appBarTitle: profile.displayName ?? profile.handle,
+18 -8
lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart
··· 14 14 import 'package:spark/src/features/feed/ui/widgets/images/image_carousel.dart'; 15 15 import 'package:spark/src/features/feed/ui/widgets/post/post_overlay.dart'; 16 16 import 'package:spark/src/features/feed/ui/widgets/videos/video_player.dart'; 17 + import 'package:spark/src/features/settings/providers/preferences_provider.dart'; 17 18 18 19 class ProfileFeedPostWidget extends ConsumerStatefulWidget { 19 20 const ProfileFeedPostWidget({ ··· 43 44 List<String> _warningLabels = []; 44 45 bool? _overrideIsLiked; 45 46 PostView? _currentPost; 47 + Future<PostView?>? _postFuture; 46 48 47 49 @override 48 50 void initState() { 49 51 super.initState(); 50 - _loadPostWithFallback().then((post) { 51 - if (post != null) { 52 + _postFuture = _loadPostWithFallback(); 53 + _postFuture!.then((post) { 54 + if (post != null && mounted) { 52 55 _checkContentWarning(post); 53 56 } 54 57 }); ··· 115 118 } 116 119 } 117 120 118 - Future<void> _checkContentWarning(PostView postData) async { 121 + void _checkContentWarning(PostView postData) { 119 122 final labels = postData.labels ?? []; 123 + final preferences = ref.read(userPreferencesProvider).asData?.value; 120 124 121 - if (labels.isNotEmpty) { 122 - final shouldShowWarning = await LabelUtils.shouldShowWarning(labels); 125 + if (labels.isNotEmpty && preferences != null) { 126 + final shouldShowWarning = LabelUtils.shouldShowWarning( 127 + preferences, 128 + labels, 129 + ); 123 130 124 - final shouldBlurContent = await LabelUtils.shouldBlurContent(labels); 131 + final shouldBlurContent = LabelUtils.shouldBlurContent( 132 + preferences, 133 + labels, 134 + ); 125 135 126 136 if (shouldShowWarning) { 127 - final warningLabels = await LabelUtils.getWarningLabels(labels); 137 + final warningLabels = LabelUtils.getWarningLabels(preferences, labels); 128 138 if (mounted) { 129 139 setState(() { 130 140 _showWarningOverlay = true; ··· 153 163 @override 154 164 Widget build(BuildContext context) { 155 165 return FutureBuilder<PostView?>( 156 - future: _loadPostWithFallback(), 166 + future: _postFuture, 157 167 builder: (context, snapshot) { 158 168 if (!snapshot.hasData) { 159 169 return const ColoredBox(
+16 -36
lib/src/features/profile/ui/widgets/profile_grid_widget.dart
··· 6 6 import 'package:skeletonizer/skeletonizer.dart'; 7 7 import 'package:spark/src/core/design_system/components/molecules/post_tile.dart'; 8 8 import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 9 - import 'package:spark/src/core/utils/label_utils.dart'; 10 9 import 'package:spark/src/features/profile/providers/profile_feed_provider.dart'; 11 10 12 11 /// Builder function that creates slivers for the profile grid ··· 137 136 ); 138 137 } 139 138 140 - class ProfileGridTile extends StatefulWidget { 139 + class ProfileGridTile extends StatelessWidget { 141 140 const ProfileGridTile({ 142 141 required this.postView, 143 142 required this.onTap, ··· 148 147 final String? postSource; 149 148 final VoidCallback onTap; 150 149 151 - @override 152 - State<ProfileGridTile> createState() => _ProfileGridTileState(); 153 - } 150 + /// Check for adult content labels synchronously without network calls 151 + bool _hasAdultLabel() { 152 + final labels = postView.labels; 153 + if (labels == null || labels.isEmpty) return false; 154 154 155 - class _ProfileGridTileState extends State<ProfileGridTile> { 156 - bool _shouldBlur = false; 157 - 158 - @override 159 - void initState() { 160 - super.initState(); 161 - _checkContentWarning(); 162 - } 163 - 164 - @override 165 - void didUpdateWidget(covariant ProfileGridTile oldWidget) { 166 - super.didUpdateWidget(oldWidget); 167 - if (widget.postView.uri != oldWidget.postView.uri) { 168 - _checkContentWarning(); 169 - } 170 - } 171 - 172 - Future<void> _checkContentWarning() async { 173 - final labels = widget.postView.labels ?? []; 174 - final shouldBlur = 175 - labels.isNotEmpty && await LabelUtils.shouldBlurContent(labels); 176 - if (mounted) { 177 - setState(() => _shouldBlur = shouldBlur); 178 - } 155 + // Check for common adult content labels synchronously 156 + const adultLabels = {'porn', 'sexual', 'nudity', 'nsfw', 'adult'}; 157 + return labels.any((label) => adultLabels.contains(label.val.toLowerCase())); 179 158 } 180 159 181 160 @override 182 161 Widget build(BuildContext context) { 183 - final thumbnailUrl = widget.postView.thumbnailUrl; 162 + final thumbnailUrl = postView.thumbnailUrl; 163 + final shouldBlur = _hasAdultLabel(); 184 164 185 165 // Use like count as a proxy for views, or 0 if not available 186 - final likeCount = widget.postView.likeCount ?? 0; 166 + final likeCount = postView.likeCount ?? 0; 187 167 188 168 if (thumbnailUrl.isEmpty) { 189 169 return GestureDetector( 190 - onTap: widget.onTap, 170 + onTap: onTap, 191 171 child: ColoredBox( 192 172 color: Theme.of(context).colorScheme.surfaceContainerHighest, 193 173 child: const Center( ··· 204 184 thumbnailUrl: thumbnailUrl, 205 185 likes: likeCount, 206 186 seen: false, 207 - nsfwBlur: _shouldBlur, 208 - onTap: widget.onTap, 187 + nsfwBlur: shouldBlur, 188 + onTap: onTap, 209 189 ), 210 - if (widget.postSource != null) 190 + if (postSource != null) 211 191 Positioned( 212 192 top: 8, 213 193 right: 8, ··· 220 200 borderRadius: BorderRadius.circular(15), 221 201 ), 222 202 child: SvgPicture.asset( 223 - widget.postSource == 'bsky' 203 + postSource == 'bsky' 224 204 ? 'images/bsky.svg' 225 205 : 'images/sprk.svg', 226 206 width: 12,
+12 -6
lib/src/features/search/providers/post_search_provider.dart
··· 11 11 import 'package:spark/src/core/utils/logging/log_service.dart'; 12 12 import 'package:spark/src/core/utils/logging/logger.dart'; 13 13 import 'package:spark/src/features/search/providers/post_search_state.dart'; 14 + import 'package:spark/src/features/settings/providers/preferences_provider.dart'; 14 15 15 16 part 'post_search_provider.g.dart'; 16 17 ··· 118 119 'Successfully converted ${bskyPosts.length}/${bskyResponse.data.posts.length} bsky posts', 119 120 ); 120 121 121 - final filteredSprkPosts = await _filterHiddenPosts(sprkResponse.posts); 122 - final filteredBskyPosts = await _filterHiddenPosts(bskyPosts); 122 + final filteredSprkPosts = _filterHiddenPosts(sprkResponse.posts); 123 + final filteredBskyPosts = _filterHiddenPosts(bskyPosts); 123 124 124 125 final combinedPosts = [...filteredSprkPosts, ...filteredBskyPosts]; 125 126 ··· 171 172 state.query, 172 173 cursor: sprkCursor, 173 174 ); 174 - final filteredPosts = await _filterHiddenPosts(response.posts); 175 + final filteredPosts = _filterHiddenPosts(response.posts); 175 176 state = state.copyWith( 176 177 searchResults: [...state.searchResults, ...filteredPosts], 177 178 sprkNextCursor: response.cursor, ··· 229 230 .toList(); 230 231 231 232 final initialCount = state.searchResults.length; 232 - final filteredBskyPosts = await _filterHiddenPosts(bskyPosts); 233 + final filteredBskyPosts = _filterHiddenPosts(bskyPosts); 233 234 state = state.copyWith( 234 235 searchResults: [...state.searchResults, ...filteredBskyPosts], 235 236 bskyNextCursor: response.data.cursor, ··· 244 245 } 245 246 } 246 247 247 - Future<List<PostView>> _filterHiddenPosts(List<PostView> posts) async { 248 + List<PostView> _filterHiddenPosts(List<PostView> posts) { 249 + final preferences = ref.read(userPreferencesProvider).asData?.value; 250 + if (preferences == null) { 251 + return posts; // Can't filter without preferences 252 + } 253 + 248 254 final filteredPosts = <PostView>[]; 249 255 for (final post in posts) { 250 - if (!await LabelUtils.shouldHideContent(post.labels ?? [])) { 256 + if (!LabelUtils.shouldHideContent(preferences, post.labels ?? [])) { 251 257 filteredPosts.add(post); 252 258 } 253 259 }
+15 -19
lib/src/features/search/ui/pages/post_results.dart
··· 5 5 import 'package:spark/src/core/routing/app_router.dart'; 6 6 import 'package:spark/src/core/utils/label_utils.dart'; 7 7 import 'package:spark/src/features/search/providers/post_search_provider.dart'; 8 + import 'package:spark/src/features/settings/providers/preferences_provider.dart'; 8 9 9 10 class PostResults extends ConsumerStatefulWidget { 10 11 const PostResults({super.key}); ··· 181 182 } 182 183 183 184 final post = state.searchResults[index]; 185 + final preferences = 186 + ref.read(userPreferencesProvider).asData?.value; 187 + final labels = post.labels ?? []; 188 + final shouldBlur = preferences != null && 189 + labels.isNotEmpty && 190 + LabelUtils.shouldBlurContent(preferences, labels); 184 191 185 - return FutureBuilder<bool>( 186 - future: () async { 187 - final labels = post.labels ?? []; 188 - return labels.isNotEmpty && 189 - await LabelUtils.shouldBlurContent(labels); 190 - }(), 191 - builder: (context, snapshot) { 192 - final shouldBlur = snapshot.data ?? false; 193 - 194 - return PostTile( 195 - thumbnailUrl: post.thumbnailUrl, 196 - likes: post.likeCount ?? 0, 197 - seen: false, 198 - nsfwBlur: shouldBlur, 199 - onTap: () { 200 - context.router.push( 201 - StandalonePostRoute(postUri: post.uri.toString()), 202 - ); 203 - }, 192 + return PostTile( 193 + thumbnailUrl: post.thumbnailUrl, 194 + likes: post.likeCount ?? 0, 195 + seen: false, 196 + nsfwBlur: shouldBlur, 197 + onTap: () { 198 + context.router.push( 199 + StandalonePostRoute(postUri: post.uri.toString()), 204 200 ); 205 201 }, 206 202 );
+100
lib/src/features/settings/providers/preferences_provider.dart
··· 1 + import 'package:get_it/get_it.dart'; 2 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 + import 'package:spark/src/core/network/atproto/data/models/pref_models.dart'; 4 + import 'package:spark/src/core/network/atproto/data/repositories/pref_repository.dart'; 5 + import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 6 + import 'package:spark/src/core/utils/logging/log_service.dart'; 7 + import 'package:spark/src/core/utils/logging/logger.dart'; 8 + 9 + part 'preferences_provider.g.dart'; 10 + 11 + /// Central provider for user preferences. 12 + /// 13 + /// This provider loads preferences once at startup and holds them in memory. 14 + /// All services that need preferences should watch this provider instead of 15 + /// calling getPreferences() directly. 16 + /// 17 + /// When preferences are updated (via [updatePreferences]), all watchers are 18 + /// automatically notified of the change. 19 + @Riverpod(keepAlive: true) 20 + class UserPreferences extends _$UserPreferences { 21 + late final PrefRepository _prefRepository; 22 + late final SprkRepository _sprkRepository; 23 + late final SparkLogger _logger; 24 + 25 + @override 26 + Future<Preferences> build() async { 27 + _prefRepository = GetIt.instance<PrefRepository>(); 28 + _sprkRepository = GetIt.instance<SprkRepository>(); 29 + _logger = GetIt.instance<LogService>().getLogger('UserPreferences'); 30 + 31 + _logger.d('Loading preferences...'); 32 + 33 + // Wait for auth to be initialized 34 + await _sprkRepository.authRepository.initializationComplete; 35 + 36 + if (!_sprkRepository.authRepository.isAuthenticated) { 37 + _logger.w('Not authenticated, returning empty preferences'); 38 + return Preferences(preferences: []); 39 + } 40 + 41 + try { 42 + final preferences = await _prefRepository.getPreferences(); 43 + _logger.d('Preferences loaded successfully'); 44 + return preferences; 45 + } catch (e) { 46 + _logger.e('Error loading preferences: $e'); 47 + rethrow; 48 + } 49 + } 50 + 51 + /// Gets the current preferences synchronously if available. 52 + /// Returns null if preferences haven't been loaded yet or there was an error. 53 + Preferences? get currentPreferences => state.asData?.value; 54 + 55 + /// Refreshes preferences from the server. 56 + /// This should be called when logging in or when syncing from another device. 57 + Future<void> refresh() async { 58 + _logger.d('Refreshing preferences from server...'); 59 + state = const AsyncValue.loading(); 60 + 61 + try { 62 + final preferences = await _prefRepository.getPreferences(); 63 + state = AsyncValue.data(preferences); 64 + _logger.d('Preferences refreshed successfully'); 65 + } catch (e, st) { 66 + _logger.e('Error refreshing preferences: $e'); 67 + state = AsyncValue.error(e, st); 68 + } 69 + } 70 + 71 + /// Updates preferences on the server and in local state. 72 + /// This should be called whenever preferences are modified. 73 + Future<void> updatePreferences(Preferences preferences) async { 74 + _logger.d('Updating preferences...'); 75 + 76 + try { 77 + await _prefRepository.putPreferences(preferences); 78 + state = AsyncValue.data(preferences); 79 + _logger.d('Preferences updated successfully'); 80 + } catch (e, st) { 81 + _logger.e('Error updating preferences: $e'); 82 + state = AsyncValue.error(e, st); 83 + rethrow; 84 + } 85 + } 86 + 87 + /// Updates preferences by applying a transformation function. 88 + /// This is useful for making partial updates without fetching first. 89 + Future<void> updatePreferencesWithFn( 90 + Preferences Function(Preferences current) updater, 91 + ) async { 92 + final current = state.asData?.value; 93 + if (current == null) { 94 + throw Exception('Cannot update preferences: not loaded yet'); 95 + } 96 + 97 + final updated = updater(current); 98 + await updatePreferences(updated); 99 + } 100 + }
+143 -106
lib/src/features/settings/providers/settings_provider.dart
··· 10 10 import 'package:spark/src/core/storage/preferences/default_preferences.dart'; 11 11 import 'package:spark/src/core/utils/logging/log_service.dart'; 12 12 import 'package:spark/src/core/utils/logging/logger.dart'; 13 + import 'package:spark/src/features/settings/providers/preferences_provider.dart'; 13 14 import 'package:spark/src/features/settings/providers/settings_state.dart'; 14 15 15 16 part 'settings_provider.g.dart'; ··· 23 24 /// StateNotifier for managing settings state 24 25 @Riverpod(keepAlive: true) 25 26 class Settings extends _$Settings { 26 - late final PrefRepository _prefRepository; 27 - late final FeedRepository _feedRepository; 28 - late final SprkRepository _sprkRepository; 29 - late final SparkLogger _logger; 30 - late final Feed _defaultFeed; 27 + FeedRepository? _feedRepository; 28 + SprkRepository? _sprkRepository; 29 + SparkLogger? _logger; 30 + Feed? _defaultFeed; 31 + 32 + /// Tracks labelers whose policies have already been fetched and set. 33 + /// This prevents repeated network calls to getServices for the same labelers. 34 + final Set<String> _labelerPoliciesChecked = {}; 35 + 36 + FeedRepository get feedRepository => 37 + _feedRepository ??= _sprkRepository!.feed; 38 + SprkRepository get sprkRepository => 39 + _sprkRepository ??= GetIt.instance<SprkRepository>(); 40 + SparkLogger get logger => 41 + _logger ??= GetIt.instance<LogService>().getLogger('Settings'); 42 + Feed get defaultFeed => _defaultFeed ??= Feed( 43 + type: 'timeline', 44 + config: SavedFeed(type: 'timeline', value: 'following', pinned: true), 45 + ); 31 46 32 47 String get _defaultModServiceDid { 33 48 // Extract DID part from modDid (remove fragment if present) 34 - final modDid = _sprkRepository.modDid; 49 + final modDid = sprkRepository.modDid; 35 50 return modDid.split('#').first; 36 51 } 37 52 53 + /// Gets the current preferences from the UserPreferences provider. 54 + /// This is the single source of truth for preferences. 55 + Preferences? get _currentPreferences => 56 + ref.read(userPreferencesProvider).asData?.value; 57 + 58 + /// Gets preferences, waiting for them to load if necessary. 59 + /// This is guaranteed to return a non-null value. 60 + Future<Preferences> _getPreferences() async { 61 + final current = _currentPreferences; 62 + if (current != null) return current; 63 + return ref.read(userPreferencesProvider.future); 64 + } 65 + 66 + /// Updates preferences through the UserPreferences provider. 67 + /// This ensures all watchers are notified of changes. 68 + Future<void> _updatePreferences(Preferences preferences) async { 69 + await ref.read(userPreferencesProvider.notifier).updatePreferences( 70 + preferences, 71 + ); 72 + } 73 + 38 74 @override 39 75 SettingsState build() { 40 - _prefRepository = ref.watch(prefRepositoryProvider); 41 - _sprkRepository = GetIt.instance<SprkRepository>(); 42 - _feedRepository = _sprkRepository.feed; 43 - _logger = GetIt.instance<LogService>().getLogger('Settings'); 44 - _defaultFeed = Feed( 45 - type: 'timeline', 46 - config: SavedFeed(type: 'timeline', value: 'following', pinned: true), 47 - ); 76 + // Watch the preferences provider - when it updates, we'll rebuild 77 + ref.watch(userPreferencesProvider); 48 78 49 79 // Load settings asynchronously but return a temporary state immediately 50 80 // This prevents blocking the UI while loading ··· 52 82 53 83 // Return temporary default state that will be replaced by loadSettings() 54 84 return SettingsState( 55 - activeFeed: _defaultFeed, 85 + activeFeed: defaultFeed, 56 86 ); 57 87 } 58 88 59 - /// Loads all settings from server preferences 89 + /// Loads all settings from the preferences provider 60 90 Future<void> loadSettings() async { 61 91 try { 62 - _logger.d('Loading settings from server...'); 92 + logger.d('Loading settings from preferences...'); 63 93 64 94 // Wait for auth to be initialized before trying to load settings 65 - final authRepository = _sprkRepository.authRepository; 95 + final authRepository = sprkRepository.authRepository; 66 96 await authRepository.initializationComplete; 67 97 68 - final preferences = await _prefRepository.getPreferences(); 98 + // Get preferences from the provider (waits for it to load if needed) 99 + final preferences = await _getPreferences(); 69 100 final savedFeeds = _getSavedFeedsFromPreferences(preferences); 70 101 71 102 // If there are no feeds, set default preferences ··· 75 106 final defaultPrefs = DefaultPreferences.defaultPreferences( 76 107 modServiceDid: modServiceDid, 77 108 ); 78 - await _prefRepository.putPreferences(defaultPrefs); 109 + await _updatePreferences(defaultPrefs); 79 110 80 - // Reload preferences after setting defaults 81 - final updatedPreferences = await _prefRepository.getPreferences(); 111 + // Get updated preferences from provider 112 + final updatedPreferences = 113 + ref.read(userPreferencesProvider).asData?.value ?? defaultPrefs; 82 114 final updatedSavedFeeds = _getSavedFeedsFromPreferences( 83 115 updatedPreferences, 84 116 ); 85 - final updatedFeeds = await _feedRepository.getFeedsFromSavedFeeds( 117 + final updatedFeeds = await feedRepository.getFeedsFromSavedFeeds( 86 118 updatedSavedFeeds, 87 119 ); 88 120 final updatedActiveFeed = _getActiveFeedFromFeeds( ··· 102 134 ); 103 135 return; 104 136 } catch (e) { 105 - _logger.e('Error setting default preferences: $e'); 137 + logger.e('Error setting default preferences: $e'); 106 138 // Continue with default feed if setting defaults fails 107 139 } 108 140 } 109 141 110 142 // Hydrate feeds with generator views using getFeedGenerators 111 - final feeds = await _feedRepository.getFeedsFromSavedFeeds(savedFeeds); 143 + final feeds = await feedRepository.getFeedsFromSavedFeeds(savedFeeds); 112 144 final activeFeed = _getActiveFeedFromFeeds(feeds, savedFeeds); 113 145 114 - _logger.d( 146 + logger.d( 115 147 'Settings loaded - activeFeed: ${activeFeed.config.value}, ' 116 148 'feeds: ${feeds.map((f) => f.config.value).join(', ')}', 117 149 ); ··· 127 159 likedFeeds: likedFeeds, 128 160 ); 129 161 130 - _logger.d('Settings state updated successfully'); 162 + logger.d('Settings state updated successfully'); 131 163 } catch (e) { 132 - _logger.e('Error loading settings: $e'); 164 + logger.e('Error loading settings: $e'); 133 165 } 134 166 } 135 167 136 168 /// Likes a feed generator 137 169 Future<void> likeFeed(Feed feed) async { 138 170 if (feed.view != null) { 139 - final likeRef = await _feedRepository.likePost( 171 + final likeRef = await feedRepository.likePost( 140 172 feed.view!.cid, 141 173 feed.view!.uri, 142 174 ); ··· 175 207 /// Unlikes a feed generator 176 208 Future<void> unlikeFeed(Feed feed) async { 177 209 if (feed.view?.viewer?.like != null) { 178 - await _feedRepository.unlikePost(feed.view!.viewer!.like!); 210 + await feedRepository.unlikePost(feed.view!.viewer!.like!); 179 211 180 212 // Update the feed to remove like information 181 213 final updatedFeed = Feed( ··· 214 246 Future<void> syncPreferencesFromServer() async { 215 247 try { 216 248 await loadSettings(); 217 - _logger.d('Preferences synced successfully'); 249 + logger.d('Preferences synced successfully'); 218 250 } catch (e) { 219 - _logger.e('Error syncing preferences from server: $e'); 251 + logger.e('Error syncing preferences from server: $e'); 220 252 } 221 253 } 222 254 223 255 /// Updates preferences with new feeds list 224 256 Future<void> _updateFeedsInPreferences(List<Feed> feeds) async { 225 - final preferences = await _prefRepository.getPreferences(); 226 - final updatedPreferences = 257 + final preferences = _currentPreferences; 258 + if (preferences == null) { 259 + logger.w('Cannot update feeds: preferences not loaded'); 260 + return; 261 + } 262 + final updatedPreferencesList = 227 263 preferences.preferences 228 264 .where((pref) => !pref.isSavedFeedsPref(pref)) 229 265 .toList() ··· 232 268 items: feeds.map((feed) => feed.config).toList(), 233 269 ), 234 270 ); 235 - await _prefRepository.putPreferences( 236 - Preferences(preferences: updatedPreferences), 237 - ); 271 + await _updatePreferences(Preferences(preferences: updatedPreferencesList)); 238 272 } 239 273 240 274 /// Adds a feed to feeds list ··· 256 290 Future<void> removeFeed(Feed feed) async { 257 291 // Prevent deletion of the Following feed 258 292 if (feed.type == 'timeline' && feed.config.value == 'following') { 259 - _logger.w('Attempted to delete the Following feed, which is not allowed'); 293 + logger.w('Attempted to delete the Following feed, which is not allowed'); 260 294 throw Exception('Cannot delete the Following feed'); 261 295 } 262 296 ··· 287 321 288 322 /// Debug method to reload settings and verify persistence 289 323 Future<void> reloadSettingsForTesting() async { 290 - _logger.d('Manually reloading settings for testing...'); 324 + logger.d('Manually reloading settings for testing...'); 291 325 await loadSettings(); 292 326 } 293 327 ··· 316 350 } 317 351 } 318 352 if (activeSavedFeed == null) { 319 - return _defaultFeed; 353 + return defaultFeed; 320 354 } 321 355 // Find the corresponding hydrated feed 322 356 try { ··· 333 367 // Public methods for other providers to use 334 368 335 369 Future<List<String>> getLabelers() async { 336 - final preferences = await _prefRepository.getPreferences(); 370 + final preferences = await _getPreferences(); 337 371 var labelers = 338 372 preferences.labelers?.map((labeler) => labeler.did).toList() ?? []; 339 373 ··· 341 375 final modServiceDid = _defaultModServiceDid; 342 376 final wasAdded = !labelers.contains(modServiceDid); 343 377 if (wasAdded) { 344 - _logger.d('Default mod service labeler not found, adding it'); 378 + logger.d('Default mod service labeler not found, adding it'); 345 379 labelers = [modServiceDid, ...labelers]; 346 380 347 381 // Update preferences to include default labeler ··· 349 383 LabelerPrefItem(did: modServiceDid), 350 384 ...(preferences.labelers ?? []), 351 385 ]; 352 - final updatedPreferences = 386 + final updatedPreferencesList = 353 387 preferences.preferences 354 388 .where((pref) => !pref.isLabelersPref(pref)) 355 389 .toList() ··· 357 391 Preference.labelersPref(labelers: updatedLabelers), 358 392 ); 359 393 360 - await _prefRepository.putPreferences( 361 - Preferences(preferences: updatedPreferences), 362 - ); 394 + await _updatePreferences(Preferences(preferences: updatedPreferencesList)); 363 395 } 364 396 365 397 // Ensure all labelers' label values are set as preferences ··· 369 401 return labelers; 370 402 } 371 403 372 - /// Ensures all label values from all subscribed labelers have preferences set 404 + /// Ensures all label values from all subscribed labelers have preferences set. 405 + /// Only fetches policies for labelers that haven't been checked yet this session. 373 406 Future<void> _ensureAllLabelersPoliciesSet(List<String> labelerDids) async { 374 - for (final did in labelerDids) { 407 + final uncheckedLabelers = labelerDids 408 + .where((did) => !_labelerPoliciesChecked.contains(did)) 409 + .toList(); 410 + 411 + if (uncheckedLabelers.isEmpty) { 412 + return; 413 + } 414 + 415 + for (final did in uncheckedLabelers) { 375 416 try { 376 417 await _fetchLabelerPoliciesAndSetDefaults(did); 418 + _labelerPoliciesChecked.add(did); 377 419 } catch (e) { 378 - _logger.w('Error ensuring label values for labeler $did: $e'); 420 + logger.w('Error ensuring label values for labeler $did: $e'); 379 421 } 380 422 } 381 423 } ··· 383 425 /// Adds a labeler to the user's subscribed labelers list 384 426 Future<void> addLabeler(String did) async { 385 427 try { 386 - _logger.d('Adding labeler: $did'); 387 - final preferences = await _prefRepository.getPreferences(); 428 + logger.d('Adding labeler: $did'); 429 + final preferences = await _getPreferences(); 388 430 final currentLabelers = preferences.labelers ?? []; 389 431 390 432 // Check if labeler already exists 391 433 if (currentLabelers.any((labeler) => labeler.did == did)) { 392 - _logger.w('Labeler already exists: $did'); 434 + logger.w('Labeler already exists: $did'); 393 435 return; 394 436 } 395 437 396 438 // Create updated preferences with new labeler 397 439 final updatedLabelers = [...currentLabelers, LabelerPrefItem(did: did)]; 398 - final updatedPreferences = 440 + final updatedPreferencesList = 399 441 preferences.preferences 400 442 .where((pref) => !pref.isLabelersPref(pref)) 401 443 .toList() ··· 403 445 Preference.labelersPref(labelers: updatedLabelers), 404 446 ); 405 447 406 - await _prefRepository.putPreferences( 407 - Preferences(preferences: updatedPreferences), 408 - ); 409 - _logger.d('Labeler added successfully: $did'); 448 + await _updatePreferences(Preferences(preferences: updatedPreferencesList)); 449 + logger.d('Labeler added successfully: $did'); 410 450 411 451 // Fetch and set default label preferences for this labeler 412 452 await _fetchLabelerPoliciesAndSetDefaults(did); 453 + _labelerPoliciesChecked.add(did); 413 454 } catch (e) { 414 - _logger.e('Error adding labeler: $e'); 455 + logger.e('Error adding labeler: $e'); 415 456 rethrow; 416 457 } 417 458 } ··· 421 462 try { 422 463 // Prevent removal of default mod service labeler 423 464 if (did == _defaultModServiceDid) { 424 - _logger.w( 465 + logger.w( 425 466 'Attempted to remove default mod service labeler, ' 426 467 'which is not allowed', 427 468 ); 428 469 throw Exception('Cannot remove the default mod service labeler'); 429 470 } 430 471 431 - _logger.d('Removing labeler: $did'); 432 - final preferences = await _prefRepository.getPreferences(); 472 + logger.d('Removing labeler: $did'); 473 + final preferences = await _getPreferences(); 433 474 final currentLabelers = preferences.labelers ?? []; 434 475 435 476 // Remove the labeler ··· 438 479 .toList(); 439 480 440 481 // Create updated preferences 441 - final updatedPreferences = 482 + final updatedPreferencesList = 442 483 preferences.preferences 443 484 .where((pref) => !pref.isLabelersPref(pref)) 444 485 .toList() ··· 446 487 Preference.labelersPref(labelers: updatedLabelers), 447 488 ); 448 489 449 - await _prefRepository.putPreferences( 450 - Preferences(preferences: updatedPreferences), 451 - ); 452 - _logger.d('Labeler removed successfully: $did'); 490 + await _updatePreferences(Preferences(preferences: updatedPreferencesList)); 491 + logger.d('Labeler removed successfully: $did'); 453 492 } catch (e) { 454 - _logger.e('Error removing labeler: $e'); 493 + logger.e('Error removing labeler: $e'); 455 494 rethrow; 456 495 } 457 496 } ··· 459 498 /// Syncs labelers from server (useful for manual refresh) 460 499 Future<void> syncLabelers() async { 461 500 try { 462 - _logger.d('Syncing labelers from server...'); 463 - // Fetch fresh preferences 464 - final preferences = await _prefRepository.getPreferences(); 501 + logger.d('Syncing labelers from server...'); 502 + // Clear the policies cache so we re-fetch them 503 + _labelerPoliciesChecked.clear(); 504 + // Refresh preferences from server first 505 + await ref.read(userPreferencesProvider.notifier).refresh(); 506 + 507 + final preferences = await _getPreferences(); 465 508 final labelers = 466 509 preferences.labelers?.map((labeler) => labeler.did).toList() ?? []; 467 510 ··· 473 516 LabelerPrefItem(did: modServiceDid), 474 517 ...(preferences.labelers ?? []), 475 518 ]; 476 - final updatedPreferences = 519 + final updatedPreferencesList = 477 520 preferences.preferences 478 521 .where((pref) => !pref.isLabelersPref(pref)) 479 522 .toList() 480 523 ..add( 481 524 Preference.labelersPref(labelers: updatedLabelers), 482 525 ); 483 - await _prefRepository.putPreferences( 484 - Preferences(preferences: updatedPreferences), 485 - ); 526 + await _updatePreferences(Preferences(preferences: updatedPreferencesList)); 486 527 } 487 528 488 - _logger.d( 529 + logger.d( 489 530 'Syncing label value preferences for ${labelers.length} labelers', 490 531 ); 491 532 492 533 // Ensure all labelers' label values are set as preferences 493 534 await _ensureAllLabelersPoliciesSet(labelers); 494 535 495 - _logger.d('Labelers synced successfully'); 536 + logger.d('Labelers synced successfully'); 496 537 } catch (e) { 497 - _logger.e('Error syncing labelers: $e'); 538 + logger.e('Error syncing labelers: $e'); 498 539 rethrow; 499 540 } 500 541 } 501 542 502 543 Future<LabelPreference> getLabelPreference(String value) async { 503 - final preferences = await _prefRepository.getPreferences(); 544 + final preferences = await _getPreferences(); 504 545 final contentLabelPrefs = preferences.contentLabelPrefs ?? []; 505 546 final contentLabelPref = contentLabelPrefs.firstWhere( 506 547 (pref) => pref.label == value, ··· 523 564 bool adultOnly, 524 565 Setting setting, 525 566 ) async { 526 - final preferences = await _prefRepository.getPreferences(); 527 - final updatedPreferences = preferences.preferences 567 + final preferences = await _getPreferences(); 568 + final updatedPreferencesList = preferences.preferences 528 569 .where((pref) => !pref.isContentLabelPref(pref)) 529 570 .toList(); 530 571 final existingContentPrefs = preferences.contentLabelPrefs ?? []; 531 572 for (final pref in existingContentPrefs) { 532 573 if (pref.label == value) { 533 - updatedPreferences.add( 574 + updatedPreferencesList.add( 534 575 Preference.contentLabelPref( 535 576 labelerDid: pref.labelerDid, 536 577 label: value, ··· 538 579 ), 539 580 ); 540 581 } else { 541 - updatedPreferences.add( 582 + updatedPreferencesList.add( 542 583 Preference.contentLabelPref( 543 584 labelerDid: pref.labelerDid, 544 585 label: pref.label, ··· 548 589 } 549 590 } 550 591 if (!existingContentPrefs.any((pref) => pref.label == value)) { 551 - updatedPreferences.add( 592 + updatedPreferencesList.add( 552 593 Preference.contentLabelPref( 553 594 labelerDid: _defaultModServiceDid, 554 595 label: value, ··· 556 597 ), 557 598 ); 558 599 } 559 - await _prefRepository.putPreferences( 560 - Preferences(preferences: updatedPreferences), 561 - ); 600 + await _updatePreferences(Preferences(preferences: updatedPreferencesList)); 562 601 } 563 602 564 603 /// Gets label preferences for a specific labeler 565 604 Future<Map<String, LabelPreference>> getLabelPreferencesForLabeler( 566 605 String labelerDid, 567 606 ) async { 568 - final preferences = await _prefRepository.getPreferences(); 607 + final preferences = await _getPreferences(); 569 608 570 609 // Get content label preferences from the main preferences list 571 610 final contentLabelPrefsMap = <String, String>{}; // label -> visibility ··· 623 662 bool adultOnly, 624 663 Setting setting, 625 664 ) async { 626 - final preferences = await _prefRepository.getPreferences(); 665 + final preferences = await _getPreferences(); 627 666 628 667 // Get all non-content-label preferences 629 - final updatedPreferences = preferences.preferences 668 + final updatedPreferencesList = preferences.preferences 630 669 .where((pref) => !pref.isContentLabelPref(pref)) 631 670 .toList(); 632 671 ··· 645 684 if (contentLabelPref.labelerDid == labelerDid && 646 685 contentLabelPref.label == value) { 647 686 // Update this specific preference 648 - updatedPreferences.add( 687 + updatedPreferencesList.add( 649 688 Preference.contentLabelPref( 650 689 labelerDid: labelerDid, 651 690 label: value, ··· 655 694 found = true; 656 695 } else { 657 696 // Keep other preferences as-is 658 - updatedPreferences.add(pref); 697 + updatedPreferencesList.add(pref); 659 698 } 660 699 } 661 700 } 662 701 663 702 // If preference doesn't exist, add it 664 703 if (!found) { 665 - updatedPreferences.add( 704 + updatedPreferencesList.add( 666 705 Preference.contentLabelPref( 667 706 labelerDid: labelerDid, 668 707 label: value, ··· 671 710 ); 672 711 } 673 712 674 - await _prefRepository.putPreferences( 675 - Preferences(preferences: updatedPreferences), 676 - ); 713 + await _updatePreferences(Preferences(preferences: updatedPreferencesList)); 677 714 } 678 715 679 716 Future<Feed> getActiveFeed() async { 680 - final preferences = await _prefRepository.getPreferences(); 717 + final preferences = await _getPreferences(); 681 718 final savedFeeds = _getSavedFeedsFromPreferences(preferences); 682 - final feeds = await _feedRepository.getFeedsFromSavedFeeds(savedFeeds); 719 + final feeds = await feedRepository.getFeedsFromSavedFeeds(savedFeeds); 683 720 return _getActiveFeedFromFeeds(feeds, savedFeeds); 684 721 } 685 722 ··· 748 785 /// for label values that don't already have preferences 749 786 Future<void> _fetchLabelerPoliciesAndSetDefaults(String did) async { 750 787 try { 751 - final rawResponse = await _sprkRepository.executeWithRetry(() async { 752 - if (!_sprkRepository.authRepository.isAuthenticated) { 788 + final rawResponse = await sprkRepository.executeWithRetry(() async { 789 + if (!sprkRepository.authRepository.isAuthenticated) { 753 790 throw Exception('Not authenticated'); 754 791 } 755 - final atproto = _sprkRepository.authRepository.atproto; 792 + final atproto = sprkRepository.authRepository.atproto; 756 793 if (atproto == null) { 757 794 throw Exception('AtProto not initialized'); 758 795 } ··· 762 799 'dids': [did], 763 800 'detailed': true, 764 801 }, 765 - headers: {'atproto-proxy': _sprkRepository.sprkDid}, 802 + headers: {'atproto-proxy': sprkRepository.sprkDid}, 766 803 to: (jsonMap) => jsonMap, 767 804 adaptor: (uint8) => 768 805 jsonDecode(utf8.decode(uint8 as List<int>)) ··· 806 843 } 807 844 } 808 845 809 - final preferences = await _prefRepository.getPreferences(); 846 + final preferences = await _getPreferences(); 810 847 811 848 // Get all existing content label preferences from both sources 812 849 final existingContentLabelPreferences = preferences.preferences ··· 861 898 862 899 if (preferencesToAdd.isNotEmpty) { 863 900 // Preserve all existing preferences and add new ones 864 - final updatedPreferences = [ 901 + final updatedPreferencesList = [ 865 902 ...preferences.preferences.where( 866 903 (pref) => !pref.isContentLabelPref(pref), 867 904 ), 868 905 ...existingContentLabelPreferences, 869 906 ...preferencesToAdd, 870 907 ]; 871 - await _prefRepository.putPreferences( 872 - Preferences(preferences: updatedPreferences), 908 + await _updatePreferences( 909 + Preferences(preferences: updatedPreferencesList), 873 910 ); 874 911 } 875 912 } catch (e) { 876 - _logger.e('Error fetching labeler policies for $did: $e'); 913 + logger.e('Error fetching labeler policies for $did: $e'); 877 914 } 878 915 } 879 916
+64 -32
pubspec.lock
··· 102 102 description: 103 103 path: "packages/atproto_core" 104 104 ref: HEAD 105 - resolved-ref: d04cef00866a4e73be8909b343946bbaaff7236d 105 + resolved-ref: "97db730543f6551e80f7e4043804d0fd36147e99" 106 106 url: "https://github.com/knotbin/atproto.dart.git" 107 107 source: git 108 108 version: "1.1.0" ··· 111 111 description: 112 112 path: "packages/atproto_oauth" 113 113 ref: HEAD 114 - resolved-ref: d04cef00866a4e73be8909b343946bbaaff7236d 114 + resolved-ref: "97db730543f6551e80f7e4043804d0fd36147e99" 115 115 url: "https://github.com/knotbin/atproto.dart.git" 116 116 source: git 117 117 version: "0.1.2" ··· 119 119 dependency: "direct main" 120 120 description: 121 121 name: audio_waveforms 122 - sha256: "71e7567f91e39458e875d3f652f7b706e1f0446adb2ff963fa81768311a68305" 122 + sha256: "03b3430ecf430a2e90185518a228c02be3d26653c62dd931e50d671213a6dbc8" 123 123 url: "https://pub.dev" 124 124 source: hosted 125 - version: "2.0.1" 125 + version: "2.0.2" 126 126 audioplayers: 127 127 dependency: "direct main" 128 128 description: ··· 239 239 dependency: transitive 240 240 description: 241 241 name: build 242 - sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413 242 + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" 243 243 url: "https://pub.dev" 244 244 source: hosted 245 - version: "4.0.3" 245 + version: "4.0.4" 246 246 build_config: 247 247 dependency: transitive 248 248 description: ··· 263 263 dependency: "direct dev" 264 264 description: 265 265 name: build_runner 266 - sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057" 266 + sha256: b4d854962a32fd9f8efc0b76f98214790b833af8b2e9b2df6bfc927c0415a072 267 267 url: "https://pub.dev" 268 268 source: hosted 269 - version: "2.10.4" 269 + version: "2.10.5" 270 270 built_collection: 271 271 dependency: transitive 272 272 description: ··· 279 279 dependency: transitive 280 280 description: 281 281 name: built_value 282 - sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" 282 + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" 283 283 url: "https://pub.dev" 284 284 source: hosted 285 - version: "8.12.1" 285 + version: "8.12.3" 286 286 cached_network_image: 287 287 dependency: "direct main" 288 288 description: ··· 319 319 dependency: transitive 320 320 description: 321 321 name: camera_android_camerax 322 - sha256: "474d8355961658d43f1c976e2fa1ca715505bea1adbd56df34c581aaa70ec41f" 322 + sha256: bc7a96998258adddd0b653dd693b0874537707d58b0489708f2a646e4f124246 323 323 url: "https://pub.dev" 324 324 source: hosted 325 - version: "0.6.26+2" 325 + version: "0.6.27" 326 326 camera_avfoundation: 327 327 dependency: transitive 328 328 description: ··· 403 403 url: "https://pub.dev" 404 404 source: hosted 405 405 version: "1.1.2" 406 + code_assets: 407 + dependency: transitive 408 + description: 409 + name: code_assets 410 + sha256: ae0db647e668cbb295a3527f0938e4039e004c80099dce2f964102373f5ce0b5 411 + url: "https://pub.dev" 412 + source: hosted 413 + version: "0.19.10" 406 414 code_builder: 407 415 dependency: transitive 408 416 description: ··· 726 734 dependency: "direct main" 727 735 description: 728 736 name: flutter_web_auth_2 729 - sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696" 737 + sha256: "7a63332eba61ddce6cc5acd7a4b26441fa0045ab6673ba40519a00dfaf87f2a9" 730 738 url: "https://pub.dev" 731 739 source: hosted 732 - version: "4.1.0" 740 + version: "5.0.0" 733 741 flutter_web_auth_2_platform_interface: 734 742 dependency: transitive 735 743 description: 736 744 name: flutter_web_auth_2_platform_interface 737 - sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d 745 + sha256: ba0fbba55bffb47242025f96852ad1ffba34bc451568f56ef36e613612baffab 738 746 url: "https://pub.dev" 739 747 source: hosted 740 - version: "4.1.0" 748 + version: "5.0.0" 741 749 flutter_web_plugins: 742 750 dependency: transitive 743 751 description: flutter ··· 803 811 dependency: "direct main" 804 812 description: 805 813 name: google_fonts 806 - sha256: eefe5ee217f331627d8bbcf01f91b21c730bf66e225d6dc8a148370b0819168d 814 + sha256: ca1cc501704c47e478f69a667d7f2d882755ddf7baad3f60c3b1256594467022 807 815 url: "https://pub.dev" 808 816 source: hosted 809 - version: "7.0.0" 817 + version: "7.0.2" 810 818 gradient_borders: 811 819 dependency: "direct main" 812 820 description: ··· 831 839 url: "https://pub.dev" 832 840 source: hosted 833 841 version: "0.2.0" 842 + hooks: 843 + dependency: transitive 844 + description: 845 + name: hooks 846 + sha256: "5410b9f4f6c9f01e8ff0eb81c9801ea13a3c3d39f8f0b1613cda08e27eab3c18" 847 + url: "https://pub.dev" 848 + source: hosted 849 + version: "0.20.5" 834 850 hotreloader: 835 851 dependency: transitive 836 852 description: ··· 1083 1099 dependency: transitive 1084 1100 description: 1085 1101 name: mockito 1086 - sha256: dac24d461418d363778d53198d9ac0510b9d073869f078450f195766ec48d05e 1102 + sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 1087 1103 url: "https://pub.dev" 1088 1104 source: hosted 1089 - version: "5.6.1" 1105 + version: "5.6.3" 1090 1106 multiformats: 1091 1107 dependency: transitive 1092 1108 description: ··· 1103 1119 url: "https://pub.dev" 1104 1120 source: hosted 1105 1121 version: "1.0.0" 1122 + native_toolchain_c: 1123 + dependency: transitive 1124 + description: 1125 + name: native_toolchain_c 1126 + sha256: f8872ea6c7a50ce08db9ae280ca2b8efdd973157ce462826c82f3c3051d154ce 1127 + url: "https://pub.dev" 1128 + source: hosted 1129 + version: "0.17.2" 1106 1130 nested: 1107 1131 dependency: transitive 1108 1132 description: ··· 1119 1143 url: "https://pub.dev" 1120 1144 source: hosted 1121 1145 version: "2.0.2" 1146 + objective_c: 1147 + dependency: transitive 1148 + description: 1149 + name: objective_c 1150 + sha256: "55eb67ede1002d9771b3f9264d2c9d30bc364f0267bc1c6cc0883280d5f0c7cb" 1151 + url: "https://pub.dev" 1152 + source: hosted 1153 + version: "9.2.2" 1122 1154 octo_image: 1123 1155 dependency: transitive 1124 1156 description: ··· 1187 1219 dependency: transitive 1188 1220 description: 1189 1221 name: path_provider_foundation 1190 - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" 1222 + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" 1191 1223 url: "https://pub.dev" 1192 1224 source: hosted 1193 - version: "2.5.1" 1225 + version: "2.6.0" 1194 1226 path_provider_linux: 1195 1227 dependency: transitive 1196 1228 description: ··· 1267 1299 dependency: "direct main" 1268 1300 description: 1269 1301 name: posthog_flutter 1270 - sha256: "134457a6fc2b47238750d077449ca40eaede5f9d5008ad4dfc872ffee9e04489" 1302 + sha256: "89fca6fb854ff47f283abfdb658868b8644afb92739155dfa10e8158b9546703" 1271 1303 url: "https://pub.dev" 1272 1304 source: hosted 1273 - version: "5.11.0" 1305 + version: "5.11.1" 1274 1306 pro_image_editor: 1275 1307 dependency: "direct main" 1276 1308 description: ··· 1457 1489 dependency: transitive 1458 1490 description: 1459 1491 name: source_gen 1460 - sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75" 1492 + sha256: "585bc140f20da42c584ece2df28f4d9ef2566955332b626f655957b3a8c8ad54" 1461 1493 url: "https://pub.dev" 1462 1494 source: hosted 1463 - version: "4.1.1" 1495 + version: "4.1.2" 1464 1496 source_helper: 1465 1497 dependency: transitive 1466 1498 description: ··· 1681 1713 dependency: transitive 1682 1714 description: 1683 1715 name: url_launcher_web 1684 - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" 1716 + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f 1685 1717 url: "https://pub.dev" 1686 1718 source: hosted 1687 - version: "2.4.1" 1719 + version: "2.4.2" 1688 1720 url_launcher_windows: 1689 1721 dependency: transitive 1690 1722 description: ··· 1761 1793 dependency: transitive 1762 1794 description: 1763 1795 name: video_player_avfoundation 1764 - sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4 1796 + sha256: "2a7aaf2f28212c285e0fb29b50728bbea513d743dd48d3024098015f169fb937" 1765 1797 url: "https://pub.dev" 1766 1798 source: hosted 1767 - version: "2.8.9" 1799 + version: "2.8.10" 1768 1800 video_player_platform_interface: 1769 1801 dependency: transitive 1770 1802 description: ··· 1934 1966 source: hosted 1935 1967 version: "3.1.3" 1936 1968 sdks: 1937 - dart: ">=3.10.0 <4.0.0" 1938 - flutter: ">=3.38.0" 1969 + dart: ">=3.10.3 <4.0.0" 1970 + flutter: ">=3.38.4"
+6 -6
pubspec.yaml
··· 16 16 path: assets 17 17 atproto: ^1.3.0 18 18 atproto_core: ^1.1.0 19 - audio_waveforms: ^2.0.0 19 + audio_waveforms: ^2.0.2 20 20 audioplayers: ^6.5.1 21 21 auto_route: ^10.1.0+1 22 22 better_player_plus: ^1.1.5 ··· 37 37 flutter_riverpod: ^3.0.3 38 38 flutter_secure_storage: ^10.0.0 39 39 flutter_svg: ^2.2.0 40 - flutter_web_auth_2: ^4.1.0 40 + flutter_web_auth_2: ^5.0.0 41 41 fonts: 42 42 path: fonts 43 43 freezed: ^3.2.3 44 44 freezed_annotation: ^3.1.0 45 45 fvp: ^0.35.2 46 46 get_it: ^9.2.0 47 - google_fonts: ^7.0.0 47 + google_fonts: ^7.0.2 48 48 gradient_borders: ^1.0.1 49 49 http: ^1.2.0 50 50 image: ^4.7.2 ··· 54 54 path: ^1.9.1 55 55 path_provider: ^2.1.2 56 56 pool: ^1.5.0 57 - posthog_flutter: ^5.11.0 57 + posthog_flutter: ^5.11.1 58 58 pro_image_editor: ^12.0.0-beta.5 59 59 pro_video_editor: 60 60 git: ··· 82 82 83 83 dev_dependencies: 84 84 auto_route_generator: ^10.2.3 85 - build_runner: ^2.10.4 85 + build_runner: ^2.10.5 86 86 flutter_launcher_icons: ^0.14.3 87 87 flutter_lints: ^6.0.0 88 88 flutter_test: 89 89 sdk: flutter 90 - json_serializable: ^6.7.1 90 + json_serializable: ^6.11.2 91 91 riverpod_generator: ^3.0.3 92 92 very_good_analysis: ^10.0.0 93 93
-2
widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift
··· 12 12 import flutter_web_auth_2 13 13 import fvp 14 14 import package_info_plus 15 - import path_provider_foundation 16 15 import posthog_flutter 17 16 import pro_video_editor 18 17 import shared_preferences_foundation ··· 30 29 FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) 31 30 FvpPlugin.register(with: registry.registrar(forPlugin: "FvpPlugin")) 32 31 FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) 33 - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) 34 32 PosthogFlutterPlugin.register(with: registry.registrar(forPlugin: "PosthogFlutterPlugin")) 35 33 ProVideoEditorPlugin.register(with: registry.registrar(forPlugin: "ProVideoEditorPlugin")) 36 34 SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))