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

fix(oauth): refresh on restore

+118 -32
+42 -10
lib/src/core/auth/data/repositories/auth_repository_impl.dart
··· 110 110 final accessToken = await StorageManager.instance.secure.getString( 111 111 StorageKeys.oauthAccessToken, 112 112 ); 113 - final refreshToken = await StorageManager.instance.secure.getString( 113 + final savedRefreshToken = await StorageManager.instance.secure.getString( 114 114 StorageKeys.oauthRefreshToken, 115 115 ); 116 116 final publicKey = await StorageManager.instance.secure.getString( ··· 121 121 ); 122 122 final savedDpopNonce = await StorageManager.instance.secure.getString( 123 123 StorageKeys.oauthDpopNonce, 124 + ); 125 + final savedExpiresAt = await StorageManager.instance.secure.getString( 126 + StorageKeys.oauthExpiresAt, 124 127 ); 125 128 final savedDid = await StorageManager.instance.secure.getString( 126 129 StorageKeys.oauthDid, ··· 136 139 ); 137 140 138 141 if (accessToken == null || 139 - refreshToken == null || 142 + savedRefreshToken == null || 140 143 publicKey == null || 141 144 privateKey == null) { 142 145 _logger.d('No saved OAuth session found'); ··· 145 148 146 149 _oauthSession = restoreOAuthSession( 147 150 accessToken: accessToken, 148 - refreshToken: refreshToken, 151 + refreshToken: savedRefreshToken, 149 152 dPoPNonce: savedDpopNonce, 150 153 publicKey: publicKey, 151 154 privateKey: privateKey, 152 155 ); 153 156 157 + // Parse expiresAt, default to epoch if not found (will trigger refresh) 158 + final expiresAt = savedExpiresAt != null 159 + ? DateTime.parse(savedExpiresAt) 160 + : DateTime.fromMillisecondsSinceEpoch(0); 161 + 154 162 _did = savedDid; 155 163 _handle = savedHandle; 156 164 _pdsEndpoint = savedPdsEndpoint; 157 165 _oauthServer = savedOAuthServer; 158 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 + } 173 + 174 + // Check if token needs refresh (5 minutes before expiration per README) 175 + if (expiresAt.isBefore(DateTime.now().add(const Duration(minutes: 5)))) { 176 + _logger.d('Access token expired or expiring soon, refreshing'); 177 + final refreshed = await refreshToken(); 178 + if (!refreshed) { 179 + _logger.w('Token refresh failed during restore, clearing session'); 180 + await _clearSavedSession(); 181 + _oauthSession = null; 182 + _atProto = null; 183 + _did = null; 184 + _handle = null; 185 + _pdsEndpoint = null; 186 + _oauthServer = null; 187 + _oauthClient = null; 188 + return; 189 + } 190 + _logger.i('Token refreshed successfully during restore'); 191 + } 192 + 159 193 // Extract just the host from the PDS endpoint 160 194 final pdsHost = _pdsEndpoint != null 161 195 ? Uri.parse(_pdsEndpoint!).host ··· 166 200 service: pdsHost, 167 201 ); 168 202 169 - // Recreate OAuth client for token refresh 170 - if (_oauthServer != null) { 171 - final metadata = await getClientMetadata(_clientMetadataUrl); 172 - _oauthClient = OAuthClient(metadata, service: _oauthServer!); 173 - _logger.d('OAuthClient recreated for session refresh'); 174 - } 175 - 176 203 _logger.i('OAuth session loaded successfully for user: $_handle'); 177 204 } catch (e) { 178 205 _logger.e('Error loading saved OAuth session', error: e); ··· 203 230 await StorageManager.instance.secure.setString( 204 231 StorageKeys.oauthDpopNonce, 205 232 _oauthSession!.$dPoPNonce, 233 + ); 234 + await StorageManager.instance.secure.setString( 235 + StorageKeys.oauthExpiresAt, 236 + _oauthSession!.expiresAt.toIso8601String(), 206 237 ); 207 238 if (_did != null) { 208 239 await StorageManager.instance.secure.setString( ··· 244 275 await StorageManager.instance.secure.remove(StorageKeys.oauthPublicKey); 245 276 await StorageManager.instance.secure.remove(StorageKeys.oauthPrivateKey); 246 277 await StorageManager.instance.secure.remove(StorageKeys.oauthDpopNonce); 278 + await StorageManager.instance.secure.remove(StorageKeys.oauthExpiresAt); 247 279 await StorageManager.instance.secure.remove(StorageKeys.oauthDid); 248 280 await StorageManager.instance.secure.remove(StorageKeys.oauthHandle); 249 281 await StorageManager.instance.secure.remove(StorageKeys.oauthPdsEndpoint);
+17 -6
lib/src/core/design_system/components/organisms/side_action_bar.dart
··· 208 208 ]); 209 209 } 210 210 211 - if (widget.soundCover != null) { 211 + // Only show sound item if cover URL is valid 212 + final soundCover = widget.soundCover; 213 + if (soundCover != null && 214 + soundCover.isNotEmpty && 215 + (soundCover.startsWith('http://') || soundCover.startsWith('https://'))) { 212 216 children.addAll([ 213 217 const SizedBox(height: 13), 214 218 _SoundItem( 215 - cover: widget.soundCover!, 219 + cover: soundCover, 216 220 onTap: widget.onSoundTap, 217 221 ), 218 222 ]); ··· 331 335 Widget build(BuildContext context) { 332 336 const albumSize = 35.0; 333 337 338 + // Don't render if cover URL is empty or invalid 339 + final hasValidCover = cover.isNotEmpty && 340 + (cover.startsWith('http://') || cover.startsWith('https://')); 341 + 334 342 return GestureDetector( 335 343 behavior: HitTestBehavior.opaque, 336 344 onTap: onTap, ··· 339 347 height: albumSize, 340 348 decoration: BoxDecoration( 341 349 shape: BoxShape.circle, 342 - image: DecorationImage( 343 - image: NetworkImage(cover), 344 - fit: BoxFit.cover, 345 - ), 350 + color: hasValidCover ? null : Colors.grey[800], 351 + image: hasValidCover 352 + ? DecorationImage( 353 + image: NetworkImage(cover), 354 + fit: BoxFit.cover, 355 + ) 356 + : null, 346 357 ), 347 358 ), 348 359 );
+16 -10
lib/src/core/network/atproto/data/models/feed_models.dart
··· 433 433 } 434 434 } 435 435 436 - // Fallback to original string 437 - return uriString; 436 + // Return empty string for unrecognized URI schemes (e.g., file://) 437 + // to prevent invalid URLs from being passed to image loaders 438 + return ''; 438 439 } 439 440 440 441 String get videoUrl { ··· 463 464 464 465 List<String> get imageUrls { 465 466 final mediaToCheck = displayMedia; 467 + final List<String> urls; 466 468 switch (mediaToCheck) { 467 469 case MediaViewImage(:final image): 468 - return [image.fullsize.toString()]; 470 + urls = [image.fullsize.toString()]; 469 471 case MediaViewImages(:final images): 470 - return images.map((img) => img.fullsize.toString()).toList(); 472 + urls = images.map((img) => img.fullsize.toString()).toList(); 471 473 case MediaViewBskyImages(:final images): 472 - return images 474 + urls = images 473 475 .map( 474 476 (img) => _resolveAtUriToHttpUrl(img.fullsize, isFullsize: true), 475 477 ) ··· 478 480 // Handle nested media in record with media 479 481 switch (media) { 480 482 case MediaViewImage(:final image): 481 - return [image.fullsize.toString()]; 483 + urls = [image.fullsize.toString()]; 482 484 case MediaViewImages(:final images): 483 - return images.map((img) => img.fullsize.toString()).toList(); 485 + urls = images.map((img) => img.fullsize.toString()).toList(); 484 486 case MediaViewBskyImages(:final images): 485 - return images 487 + urls = images 486 488 .map( 487 489 (img) => 488 490 _resolveAtUriToHttpUrl(img.fullsize, isFullsize: true), 489 491 ) 490 492 .toList(); 491 493 case _: 492 - return []; 494 + urls = []; 493 495 } 494 496 case _: 495 - return []; 497 + urls = []; 496 498 } 499 + // Filter out invalid URLs (must be http/https) 500 + return urls 501 + .where((url) => url.startsWith('http://') || url.startsWith('https://')) 502 + .toList(); 497 503 } 498 504 499 505 String get thumbnailUrl {
+1
lib/src/core/storage/preferences/storage_constants.dart
··· 12 12 static const String oauthPublicKey = 'oauth_public_key'; 13 13 static const String oauthPrivateKey = 'oauth_private_key'; 14 14 static const String oauthDpopNonce = 'oauth_dpop_nonce'; 15 + static const String oauthExpiresAt = 'oauth_expires_at'; 15 16 static const String oauthPendingContext = 'oauth_pending_context'; 16 17 static const String oauthDid = 'oauth_did'; 17 18 static const String oauthHandle = 'oauth_handle';
+17 -3
lib/src/features/feed/ui/widgets/images/image_carousel.dart
··· 5 5 import 'package:spark/src/core/ui/foundation/colors.dart'; 6 6 7 7 class ImageCarousel extends ConsumerStatefulWidget { 8 - const ImageCarousel({required this.imageUrls, super.key, this.alts}); 8 + const ImageCarousel({ 9 + required this.imageUrls, 10 + super.key, 11 + this.alts, 12 + this.hasKnownInteractions = false, 13 + }); 9 14 final List<String> imageUrls; 10 15 final List<String>? alts; 16 + final bool hasKnownInteractions; 11 17 12 18 @override 13 19 ConsumerState<ImageCarousel> createState() => _ImageCarouselState(); ··· 122 128 123 129 @override 124 130 Widget build(BuildContext context) { 131 + // Handle empty image URLs gracefully 132 + if (widget.imageUrls.isEmpty) { 133 + return const DecoratedBox( 134 + decoration: BoxDecoration(color: AppColors.black), 135 + ); 136 + } 137 + 125 138 final hasMultipleImages = widget.imageUrls.length > 1; 126 139 127 140 // If only one image, show it directly without carousel ··· 145 158 ), 146 159 Positioned( 147 160 // Position dots above the post overlay content area 148 - // The overlay has content (InfoBar, SideActionBar) in the bottom ~150px 149 - bottom: 180, 161 + // When known interactions exist, they add ~48px (bar height + 12px spacing) 162 + // Base position is 180px, reduced by 48px when no known interactions 163 + bottom: widget.hasKnownInteractions ? 180 : 132, 150 164 left: 0, 151 165 right: 0, 152 166 child: Center(
+15 -2
lib/src/features/feed/ui/widgets/post/feed_post_widget.dart
··· 252 252 thumbnail: postData.thumbnailUrl, 253 253 ), 254 254 MediaViewImages() || MediaViewBskyImages() => 255 - ImageCarousel(imageUrls: postData.imageUrls), 255 + ImageCarousel( 256 + imageUrls: postData.imageUrls, 257 + hasKnownInteractions: currentPost.viewer 258 + ?.knownInteractions != 259 + null && 260 + currentPost.viewer!.knownInteractions!.isNotEmpty, 261 + ), 256 262 MediaViewBskyRecordWithMedia(:final media) => 257 263 switch (media) { 258 264 MediaViewVideo() => PostVideoPlayer( ··· 270 276 thumbnail: postData.thumbnailUrl, 271 277 ), 272 278 MediaViewImages() || MediaViewBskyImages() => 273 - ImageCarousel(imageUrls: postData.imageUrls), 279 + ImageCarousel( 280 + imageUrls: postData.imageUrls, 281 + hasKnownInteractions: currentPost.viewer 282 + ?.knownInteractions != 283 + null && 284 + currentPost.viewer!.knownInteractions! 285 + .isNotEmpty, 286 + ), 274 287 _ => const DecoratedBox( 275 288 decoration: BoxDecoration(color: AppColors.black), 276 289 ),
+10 -1
lib/src/features/profile/ui/widgets/profile_feed_post_widget.dart
··· 209 209 ), 210 210 MediaViewImages() || MediaViewBskyImages() => ImageCarousel( 211 211 imageUrls: post.imageUrls, 212 + hasKnownInteractions: post.viewer?.knownInteractions != 213 + null && 214 + post.viewer!.knownInteractions!.isNotEmpty, 212 215 ), 213 216 MediaViewBskyRecordWithMedia(:final media) => 214 217 switch (media) { ··· 229 232 index: widget.index, 230 233 ), 231 234 MediaViewImages() || MediaViewBskyImages() => 232 - ImageCarousel(imageUrls: post.imageUrls), 235 + ImageCarousel( 236 + imageUrls: post.imageUrls, 237 + hasKnownInteractions: post.viewer 238 + ?.knownInteractions != 239 + null && 240 + post.viewer!.knownInteractions!.isNotEmpty, 241 + ), 233 242 _ => const DecoratedBox( 234 243 decoration: BoxDecoration(color: AppColors.black), 235 244 ),