[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.

add profile caching and pull to refresh (#26)

authored by

Davi Rodrigues and committed by
GitHub
2ad3e019 8942a872

+162 -67
+71 -29
lib/screens/profile_screen.dart
··· 214 214 final actionsService = Provider.of<ActionsService>(context, listen: false); 215 215 216 216 try { 217 - // Toggle follow status 218 217 final newFollowUri = await actionsService.toggleFollow(_profile!.did, _profile!.followUri); 219 218 220 219 if (!mounted) return; ··· 273 272 ); 274 273 275 274 if (result) { 275 + if (!context.mounted) return; 276 276 ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Report submitted successfully'))); 277 277 } 278 278 } catch (e) { 279 + if (!context.mounted) return; 279 280 ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error submitting report: $e'))); 280 281 } 281 282 }, ··· 283 284 ); 284 285 } 285 286 287 + /// Refreshes the profile by forcing a refetch from the server and clearing cache 288 + Future<void> _refreshProfile() async { 289 + if (!mounted) return; 290 + 291 + final targetDid = widget.did ?? Provider.of<AuthService>(context, listen: false).session?.did; 292 + if (targetDid == null) return; 293 + 294 + try { 295 + final profileService = Provider.of<ProfileService>(context, listen: false); 296 + 297 + await profileService.clearProfileCache(targetDid); 298 + 299 + final profile = await profileService.getProfile(targetDid, forceRefresh: true); 300 + 301 + if (!mounted) return; 302 + 303 + setState(() { 304 + _profile = profile; 305 + }); 306 + 307 + final isSupporter = await _checkEarlySupporter(targetDid); 308 + 309 + if (!mounted) return; 310 + 311 + setState(() { 312 + _isEarlySupporter = isSupporter; 313 + }); 314 + } catch (e) { 315 + if (!mounted) return; 316 + 317 + setState(() { 318 + _error = e.toString(); 319 + }); 320 + 321 + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error refreshing profile: ${e.toString()}'))); 322 + } 323 + } 324 + 286 325 @override 287 326 Widget build(BuildContext context) { 288 327 super.build(context); // Required for AutomaticKeepAliveClientMixin ··· 351 390 padding: const EdgeInsets.only(right: 8.0), 352 391 child: MenuActionButton( 353 392 onPressed: _handleReportProfile, 354 - backgroundColor: isDarkMode ? Colors.black.withOpacity(0.15) : Colors.grey.withOpacity(0.1), 393 + backgroundColor: isDarkMode ? Colors.black.withValues(alpha: 0.15) : Colors.grey.withValues(alpha: 0.1), 355 394 isProfile: true, 356 395 ), 357 396 ), 358 397 ], 359 398 ), 360 399 body: SafeArea( 361 - child: CustomScrollView( 362 - key: PageStorageKey<String>('profile_${widget.did ?? 'current'}'), 363 - slivers: [ 364 - // Profile header 365 - SliverToBoxAdapter( 366 - child: ProfileHeader( 367 - profile: _profile!, 368 - isCurrentUser: isCurrentUser, 369 - isEarlySupporter: _isEarlySupporter, 370 - onEarlySupporterTap: () => _showEarlySupporterInfo(context), 371 - onEditTap: () => _checkAuthAndProceed(_navigateToEdit), 372 - onShareTap: () => debugPrint('Share profile tapped'), 373 - onFollowTap: () => _checkAuthAndProceed(_handleFollow), 374 - onSettingsTap: _handleSettingsTap, 400 + child: RefreshIndicator( 401 + onRefresh: _refreshProfile, 402 + child: CustomScrollView( 403 + key: PageStorageKey<String>('profile_${widget.did ?? 'current'}'), 404 + slivers: [ 405 + // Profile header 406 + SliverToBoxAdapter( 407 + child: ProfileHeader( 408 + profile: _profile!, 409 + isCurrentUser: isCurrentUser, 410 + isEarlySupporter: _isEarlySupporter, 411 + onEarlySupporterTap: () => _showEarlySupporterInfo(context), 412 + onEditTap: () => _checkAuthAndProceed(_navigateToEdit), 413 + onShareTap: () => debugPrint('Share profile tapped'), 414 + onFollowTap: () => _checkAuthAndProceed(_handleFollow), 415 + onSettingsTap: _handleSettingsTap, 416 + ), 375 417 ), 376 - ), 377 418 378 - // Tab bar (pinned) 379 - SliverPersistentHeader( 380 - pinned: true, 381 - delegate: StickyTabBarDelegate( 382 - child: ProfileTabs( 383 - selectedIndex: _selectedTabIndex, 384 - onTabSelected: _handleTabChange, 385 - isAuthenticated: isAuthenticated, 419 + // Tab bar (pinned) 420 + SliverPersistentHeader( 421 + pinned: true, 422 + delegate: StickyTabBarDelegate( 423 + child: ProfileTabs( 424 + selectedIndex: _selectedTabIndex, 425 + onTabSelected: _handleTabChange, 426 + isAuthenticated: isAuthenticated, 427 + ), 386 428 ), 387 429 ), 388 - ), 389 430 390 - // Dynamic tab content 391 - ...tabContent.getTabContent(), 392 - ], 431 + // Dynamic tab content 432 + ...tabContent.getTabContent(), 433 + ], 434 + ), 393 435 ), 394 436 ), 395 437 );
+91 -38
lib/services/profile_service.dart
··· 1 + import 'dart:convert'; 2 + 1 3 import 'package:atproto/core.dart'; 2 4 import 'package:bluesky/bluesky.dart'; 3 5 import 'package:flutter/foundation.dart'; 6 + import 'package:flutter_cache_manager/flutter_cache_manager.dart'; 4 7 5 8 import '../models/profile.dart'; 6 9 import 'auth_service.dart'; ··· 8 11 9 12 class ProfileService extends ChangeNotifier { 10 13 final AuthService _authService; 14 + final DefaultCacheManager _cacheManager = DefaultCacheManager(); 15 + 16 + static const Duration cacheDuration = Duration(minutes: 10); 11 17 12 18 ProfileService(this._authService); 13 19 14 - Future<Profile?> getProfile(String did) async { 15 - if (!_authService.isAuthenticated) { 16 - return null; 17 - } 20 + String _getBskyCacheKey(String did) => 'bsky_profile_$did'; 21 + 22 + String _getSprkCacheKey(String did) => 'sprk_profile_$did'; 18 23 19 - // Check for existing follow first 20 - String? existingFollowUri; 21 - try { 22 - final existingFollows = await _authService.atproto!.repo.listRecords( 23 - repo: _authService.session!.did, 24 - collection: NSID.parse('so.sprk.graph.follow'), 25 - ); 24 + Future<Profile?> getProfile(String did, {bool forceRefresh = false}) async { 25 + if (!_authService.isAuthenticated) return null; 26 26 27 - // Find the follow record for this DID if it exists 28 - for (final record in existingFollows.data.records) { 29 - if (record.value['subject'] == did) { 30 - existingFollowUri = record.uri.toString(); 31 - break; 32 - } 33 - } 34 - } catch (e) { 35 - debugPrint('Error checking existing follows: $e'); 36 - } 27 + String? existingFollowUri = await _getExistingFollowUri(did); 37 28 38 29 // Try Spark profile first 39 30 try { 40 - final sprkProfile = await getProfileFullSprk(did); 31 + final sprkProfile = await getProfileFullSprk(did, forceRefresh: forceRefresh); 41 32 if (sprkProfile != null) { 42 33 final viewer = sprkProfile['viewer'] as Map<dynamic, dynamic>?; 43 34 return Profile.fromSparkProfile({ ··· 46 37 'source': 'spark', 47 38 }); 48 39 } 49 - return null; 50 40 } catch (e) { 51 41 debugPrint('Error fetching Spark profile: $e'); 52 42 // Only continue to Bluesky if it's a 404-like error ··· 58 48 59 49 // Try Bluesky profile if Spark fails with 404 60 50 try { 61 - final bskyProfile = await getProfileFullBsky(did); 51 + final bskyProfile = await getProfileFullBsky(did, forceRefresh: forceRefresh); 62 52 if (bskyProfile != null) { 63 53 final profile = Profile.fromBlueskyActor(bskyProfile); 64 54 final counts = bskyProfile.toJson(); ··· 72 62 try { 73 63 final sparkFollowers = await client.graph.getFollowers(did); 74 64 75 - try { 76 - final followers = sparkFollowers.data['followers'] as List; 77 - followersCount += followers.length; 78 - } catch (e) {} 65 + final followers = sparkFollowers.data['followers'] as List; 66 + followersCount += followers.length; 79 67 } catch (e) { 80 68 debugPrint('Error fetching Spark followers: $e'); 81 69 // Continue anyway ··· 84 72 try { 85 73 final sparkFollows = await client.graph.getFollows(did); 86 74 87 - try { 88 - final follows = sparkFollows.data['follows'] as List; 89 - followingCount += follows.length; 90 - } catch (e) {} 75 + final follows = sparkFollows.data['follows'] as List; 76 + followingCount += follows.length; 91 77 } catch (e) { 92 78 debugPrint('Error fetching Spark follows: $e'); 93 79 } ··· 109 95 return null; 110 96 } 111 97 112 - Future<ActorProfile?> getProfileFullBsky(String did) async { 113 - if (!_authService.isAuthenticated) { 114 - return null; 98 + // Helper method to get existing follow URI 99 + Future<String?> _getExistingFollowUri(String did) async { 100 + try { 101 + final existingFollows = await _authService.atproto!.repo.listRecords( 102 + repo: _authService.session!.did, 103 + collection: NSID.parse('so.sprk.graph.follow'), 104 + ); 105 + 106 + // Find the follow record for this DID if it exists 107 + for (final record in existingFollows.data.records) { 108 + if (record.value['subject'] == did) { 109 + return record.uri.toString(); 110 + } 111 + } 112 + } catch (e) { 113 + debugPrint('Error checking existing follows: $e'); 115 114 } 115 + return null; 116 + } 116 117 118 + Future<ActorProfile?> getProfileFullBsky(String did, {bool forceRefresh = false}) async { 119 + if (!_authService.isAuthenticated) return null; 120 + 121 + final cacheKey = _getBskyCacheKey(did); 122 + 123 + // First check cache if not forcing refresh 124 + if (!forceRefresh) { 125 + try { 126 + final cacheFile = await _cacheManager.getFileFromCache(cacheKey); 127 + if (cacheFile != null) { 128 + final jsonString = await cacheFile.file.readAsString(); 129 + final profileData = json.decode(jsonString); 130 + return ActorProfile.fromJson(profileData); 131 + } 132 + } catch (e) { 133 + debugPrint('Error reading from cache: $e'); 134 + // Continue to fetch from network 135 + } 136 + } 137 + 138 + // Fetch from network 117 139 try { 118 140 final bsky = Bluesky.fromSession(_authService.session!); 119 141 final response = await bsky.actor.getProfile(actor: did); 142 + 143 + // Cache the response 144 + final profileJson = response.data.toJson(); 145 + final jsonString = json.encode(profileJson); 146 + await _cacheManager.putFile(cacheKey, Uint8List.fromList(utf8.encode(jsonString)), key: cacheKey, maxAge: cacheDuration); 147 + 120 148 return response.data; 121 149 } catch (e) { 122 150 throw Exception('Failed to fetch profile: $e'); 123 151 } 124 152 } 125 153 126 - Future<Map<String, dynamic>?> getProfileFullSprk(String did) async { 127 - if (!_authService.isAuthenticated) { 128 - return null; 154 + Future<Map<String, dynamic>?> getProfileFullSprk(String did, {bool forceRefresh = false}) async { 155 + if (!_authService.isAuthenticated) return null; 156 + 157 + final cacheKey = _getSprkCacheKey(did); 158 + 159 + // First check cache if not forcing refresh 160 + if (!forceRefresh) { 161 + try { 162 + final cacheFile = await _cacheManager.getFileFromCache(cacheKey); 163 + if (cacheFile != null) { 164 + final jsonString = await cacheFile.file.readAsString(); 165 + return json.decode(jsonString); 166 + } 167 + } catch (e) { 168 + debugPrint('Error reading from cache: $e'); 169 + // Continue to fetch from network 170 + } 129 171 } 130 172 173 + // Fetch from network 131 174 try { 132 175 final client = SprkClient(_authService); 133 176 final profileRes = await client.actor.getProfile(did); ··· 140 183 profile['viewer'] = {}; 141 184 } 142 185 186 + // Cache the response 187 + final jsonString = json.encode(profile); 188 + await _cacheManager.putFile(cacheKey, Uint8List.fromList(utf8.encode(jsonString)), key: cacheKey, maxAge: cacheDuration); 189 + 143 190 return profile; 144 191 } catch (e) { 145 192 throw Exception('Failed to fetch profile: $e'); 146 193 } 194 + } 195 + 196 + // Clear all profile caches for a specific DID 197 + Future<void> clearProfileCache(String did) async { 198 + await _cacheManager.removeFile(_getBskyCacheKey(did)); 199 + await _cacheManager.removeFile(_getSprkCacheKey(did)); 147 200 } 148 201 149 202 Future<Map<String, dynamic>?> getProfileVideosSprk(String did) async {