[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: android image rendering

+218 -127
+5 -6
lib/src/core/design_system/components/molecules/feed_card.dart
··· 8 8 import 'package:spark/src/core/design_system/tokens/typography.dart'; 9 9 import 'package:spark/src/core/l10n/app_localizations.dart'; 10 10 import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 11 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 11 12 12 13 class FeedCard extends StatelessWidget { 13 14 const FeedCard({ ··· 55 56 String? get _description => generator?.description; 56 57 57 58 String get _avatarUrl { 58 - if (generator?.avatar != null) { 59 - return generator!.avatar.toString(); 60 - } 61 - return ''; 59 + return resolveImageUrlObject(generator?.avatar) ?? ''; 62 60 } 63 61 64 62 bool get _isPrimaryAction => !isAdded || !isPinned; ··· 248 246 @override 249 247 Widget build(BuildContext context) { 250 248 final isDark = Theme.of(context).brightness == Brightness.dark; 249 + final resolvedImageUrl = resolveImageUrlString(imageUrl); 251 250 252 - if (imageUrl.isEmpty) { 251 + if (resolvedImageUrl == null) { 253 252 return _FallbackAvatar(isDark: isDark); 254 253 } 255 254 ··· 257 256 borderRadius: BorderRadius.circular(8), 258 257 child: CachedNetworkImage( 259 258 fadeInDuration: Duration.zero, 260 - imageUrl: imageUrl, 259 + imageUrl: resolvedImageUrl, 261 260 width: 36, 262 261 height: 36, 263 262 fit: BoxFit.cover,
+10 -3
lib/src/core/design_system/components/molecules/post_tile.dart
··· 7 7 import 'package:spark/src/core/design_system/tokens/colors.dart'; 8 8 import 'package:spark/src/core/design_system/tokens/shapes.dart'; 9 9 import 'package:spark/src/core/design_system/tokens/typography.dart'; 10 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 10 11 11 12 class PostTile extends StatelessWidget { 12 13 final String thumbnailUrl; ··· 31 32 32 33 @override 33 34 Widget build(BuildContext context) { 35 + final resolvedThumbnailUrl = resolveImageUrlString(thumbnailUrl); 34 36 // Squircle shape from design tokens 35 37 final BorderRadiusGeometry radius = BorderRadius.circular( 36 38 AppShapes.squircleRadius, ··· 55 57 child: Stack( 56 58 fit: StackFit.expand, 57 59 children: [ 58 - if (nsfwBlur) 60 + if (resolvedThumbnailUrl == null) 61 + const ColoredBox( 62 + color: AppColors.grey800, 63 + child: Icon(Icons.broken_image, color: AppColors.grey400), 64 + ) 65 + else if (nsfwBlur) 59 66 ImageFiltered( 60 67 imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), 61 68 child: CachedNetworkImage( 62 69 fadeInDuration: Duration.zero, 63 - imageUrl: thumbnailUrl, 70 + imageUrl: resolvedThumbnailUrl, 64 71 fit: BoxFit.cover, 65 72 placeholder: (context, url) => 66 73 const ColoredBox(color: AppColors.grey800), ··· 73 80 else 74 81 CachedNetworkImage( 75 82 fadeInDuration: Duration.zero, 76 - imageUrl: thumbnailUrl, 83 + imageUrl: resolvedThumbnailUrl, 77 84 fit: BoxFit.cover, 78 85 placeholder: (context, url) => 79 86 const ColoredBox(color: AppColors.grey800),
+4 -2
lib/src/core/design_system/components/molecules/profile_avatar.dart
··· 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:spark/src/core/design_system/tokens/gradients.dart'; 5 5 import 'package:spark/src/core/ui/foundation/colors.dart'; 6 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 6 7 7 8 class ProfileAvatar extends StatelessWidget { 8 9 const ProfileAvatar({ ··· 111 112 required bool isDarkMode, 112 113 required double avatarSize, 113 114 }) { 114 - if (avatarUrl != null && avatarUrl!.isNotEmpty) { 115 + final resolvedAvatarUrl = resolveImageUrlString(avatarUrl); 116 + if (resolvedAvatarUrl != null) { 115 117 return ClipOval( 116 118 child: CachedNetworkImage( 117 119 fadeInDuration: Duration.zero, 118 - imageUrl: avatarUrl!, 120 + imageUrl: resolvedAvatarUrl, 119 121 width: avatarSize, 120 122 height: avatarSize, 121 123 fit: BoxFit.cover,
+2 -4
lib/src/core/design_system/components/molecules/settings_feed_card.dart
··· 7 7 import 'package:spark/src/core/design_system/tokens/typography.dart'; 8 8 import 'package:spark/src/core/l10n/app_localizations.dart'; 9 9 import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 10 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 10 11 11 12 enum SettingsFeedCardMode { display, edit } 12 13 ··· 60 61 } 61 62 62 63 String get _avatarUrl { 63 - if (generator?.avatar != null) { 64 - return generator!.avatar.toString(); 65 - } 66 - return ''; 64 + return resolveImageUrlObject(generator?.avatar) ?? ''; 67 65 } 68 66 69 67 @override
+4 -2
lib/src/core/design_system/components/molecules/story_circle.dart
··· 4 4 import 'package:spark/src/core/design_system/tokens/gradients.dart'; 5 5 import 'package:spark/src/core/design_system/tokens/typography.dart'; 6 6 import 'package:spark/src/core/ui/foundation/colors.dart'; 7 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 7 8 8 9 enum StoryType { story, live, cf, create } 9 10 ··· 84 85 Widget build(BuildContext context) { 85 86 final hasStoryRing = type != StoryType.create; 86 87 final ringColor = _getRingColor(); 88 + final resolvedImageUrl = resolveImageUrlString(imageUrl); 87 89 88 90 return SizedBox( 89 91 width: _widgetWidth, ··· 111 113 child: Padding( 112 114 padding: const EdgeInsets.all(_ringGap), 113 115 child: ClipOval( 114 - child: imageUrl.isNotEmpty 116 + child: resolvedImageUrl != null 115 117 ? CachedNetworkImage( 116 118 fadeInDuration: Duration.zero, 117 - imageUrl: imageUrl, 119 + imageUrl: resolvedImageUrl, 118 120 width: _imageSize, 119 121 height: _imageSize, 120 122 fit: BoxFit.cover,
+36 -50
lib/src/core/network/atproto/data/models/feed_models.dart
··· 5 5 import 'package:freezed_annotation/freezed_annotation.dart'; 6 6 import 'package:spark/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart'; 7 7 import 'package:spark/src/core/network/atproto/data/models/models.dart'; 8 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 8 9 import 'package:spark/src/core/utils/uri_converter.dart'; 9 10 10 11 part 'feed_models.freezed.dart'; ··· 386 387 387 388 /// Resolves AT Protocol blob URLs to HTTP URLs for display 388 389 String _resolveAtUriToHttpUrl(Uri uri, {bool isFullsize = false}) { 389 - final uriString = uri.toString(); 390 - 391 - // If it's already an HTTP URL, return as is 392 - if (uriString.startsWith('http://') || uriString.startsWith('https://')) { 393 - return uriString; 394 - } 395 - 396 - // If it's an AT Protocol blob URL, convert to Bluesky CDN URL 397 - if (uriString.startsWith('at://')) { 398 - // Parse AT URI format: at://did/collection/rkey 399 - final match = RegExp( 400 - r'^at://([^/]+)/([^/]+)/(.+)$', 401 - ).firstMatch(uriString); 402 - if (match != null) { 403 - final did = match.group(1)!; 404 - final collection = match.group(2)!; 405 - final rkey = match.group(3)!; 406 - 407 - // For blob collections, use Bluesky's CDN 408 - if (collection == 'blob') { 409 - if (isFullsize) { 410 - return 'https://cdn.bsky.app/img/feed_fullsize/plain/$did/$rkey@jpeg'; 411 - } else { 412 - return 'https://cdn.bsky.app/img/feed_thumbnail/plain/$did/$rkey@jpeg'; 413 - } 414 - } 415 - } 416 - } 417 - 418 - // Return empty string for unrecognized URI schemes (e.g., file://) 419 - // to prevent invalid URLs from being passed to image loaders 420 - return ''; 390 + return resolveImageUrlOrEmpty(uri, isFullsize: isFullsize); 421 391 } 422 392 423 393 String get videoUrl { ··· 446 416 447 417 List<String> get imageUrls { 448 418 final mediaToCheck = displayMedia; 449 - final List<String> urls; 419 + final List<String?> urls; 450 420 switch (mediaToCheck) { 451 421 case MediaViewImage(:final image): 452 - urls = [image.fullsize.toString()]; 422 + urls = [resolveImageUrlObject(image.fullsize, isFullsize: true)]; 453 423 case MediaViewImages(:final images): 454 - urls = images.map((img) => img.fullsize.toString()).toList(); 424 + urls = images 425 + .map((img) => resolveImageUrlObject(img.fullsize, isFullsize: true)) 426 + .toList(); 455 427 case MediaViewBskyImages(:final images): 456 428 urls = images 457 429 .map( ··· 462 434 // Handle nested media in record with media 463 435 switch (media) { 464 436 case MediaViewImage(:final image): 465 - urls = [image.fullsize.toString()]; 437 + urls = [resolveImageUrlObject(image.fullsize, isFullsize: true)]; 466 438 case MediaViewImages(:final images): 467 - urls = images.map((img) => img.fullsize.toString()).toList(); 439 + urls = images 440 + .map( 441 + (img) => 442 + resolveImageUrlObject(img.fullsize, isFullsize: true), 443 + ) 444 + .toList(); 468 445 case MediaViewBskyImages(:final images): 469 446 urls = images 470 447 .map( ··· 478 455 case _: 479 456 urls = []; 480 457 } 481 - // Filter out invalid URLs (must be http/https) 482 - return urls 483 - .where((url) => url.startsWith('http://') || url.startsWith('https://')) 484 - .toList(); 458 + return urls.whereType<String>().toList(); 485 459 } 486 460 487 461 String get thumbnailUrl { 488 462 final mediaToCheck = displayMedia; 489 463 switch (mediaToCheck) { 490 464 case MediaViewVideo(:final thumbnail): 491 - return thumbnail.toString(); 465 + return resolveImageUrlOrEmpty(thumbnail); 492 466 case MediaViewBskyVideo(:final thumbnail): 493 467 return _resolveAtUriToHttpUrl(thumbnail); 494 468 case MediaViewImage(:final image): 495 - return image.thumb.toString(); 469 + return resolveImageUrlOrEmpty(image.thumb); 496 470 case MediaViewImages(:final images): 497 - return images.first.thumb.toString(); 471 + return resolveImageUrlOrEmpty(images.first.thumb); 498 472 case MediaViewBskyImages(:final images): 499 473 return _resolveAtUriToHttpUrl(images.first.thumb); 500 474 case MediaViewBskyRecordWithMedia(:final media): 501 475 // Handle nested media in record with media 502 476 switch (media) { 503 477 case MediaViewVideo(:final thumbnail): 504 - return thumbnail.toString(); 478 + return resolveImageUrlOrEmpty(thumbnail); 505 479 case MediaViewBskyVideo(:final thumbnail): 506 480 return _resolveAtUriToHttpUrl(thumbnail); 507 481 case MediaViewImage(:final image): 508 - return image.thumb.toString(); 482 + return resolveImageUrlOrEmpty(image.thumb); 509 483 case MediaViewImages(:final images): 510 - return images.first.thumb.toString(); 484 + return resolveImageUrlOrEmpty(images.first.thumb); 511 485 case MediaViewBskyImages(:final images): 512 486 return _resolveAtUriToHttpUrl(images.first.thumb); 513 487 case _: ··· 848 822 ThreadPostView(:final post) => post.imageUrls, 849 823 ThreadReplyView(:final reply) => switch (reply.hydratedMedia) { 850 824 // Replies/comments only support a single image (EmbedViewMediaImage) 851 - MediaViewImage(:final image) => [image.fullsize.toString()], 825 + MediaViewImage(:final image) => [ 826 + resolveImageUrlObject(image.fullsize, isFullsize: true), 827 + ].whereType<String>().toList(), 852 828 MediaViewImages(:final images) => 853 - images.map((img) => img.fullsize.toString()).toList(), 829 + images 830 + .map( 831 + (img) => resolveImageUrlOrEmpty(img.fullsize, isFullsize: true), 832 + ) 833 + .where((url) => url.isNotEmpty) 834 + .toList(), 854 835 MediaViewBskyImages(:final images) => 855 - images.map((img) => img.fullsize.toString()).toList(), 836 + images 837 + .map( 838 + (img) => resolveImageUrlOrEmpty(img.fullsize, isFullsize: true), 839 + ) 840 + .where((url) => url.isNotEmpty) 841 + .toList(), 856 842 _ => <String>[], 857 843 }, 858 844 };
+4 -4
lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart
··· 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:spark/src/core/l10n/app_localizations.dart'; 4 4 import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 5 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 5 6 import 'package:spark/src/features/search/providers/actor_typeahead_provider.dart'; 6 7 import 'package:spark/src/features/search/providers/actor_typeahead_state.dart'; 7 8 ··· 163 164 separatorBuilder: (_, _) => const Divider(color: Color(0x1FFFFFFF)), 164 165 itemBuilder: (context, index) { 165 166 final actor = typeaheadState.results[index]; 167 + final avatarUrl = resolveImageUrlObject(actor.avatar); 166 168 return ListTile( 167 169 contentPadding: EdgeInsets.zero, 168 170 leading: CircleAvatar( 169 171 backgroundColor: const Color(0x1AFFFFFF), 170 - backgroundImage: actor.avatar != null 171 - ? NetworkImage(actor.avatar.toString()) 172 - : null, 173 - child: actor.avatar == null 172 + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, 173 + child: avatarUrl == null 174 174 ? const Icon(Icons.person_outline, color: Colors.white) 175 175 : null, 176 176 ),
+2 -1
lib/src/core/storage/cache/download_manager_impl.dart
··· 5 5 import 'package:pool/pool.dart'; 6 6 import 'package:spark/src/core/network/atproto/data/models/models.dart'; 7 7 import 'package:spark/src/core/storage/cache/download_manager_interface.dart'; 8 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 8 9 import 'package:spark/src/core/utils/logging/logging.dart'; 9 10 import 'package:spark/src/features/feed/providers/feed_state.dart'; 10 11 ··· 221 222 task.post.videoUrl, 222 223 placeholder: CachedNetworkImage( 223 224 fadeInDuration: Duration.zero, 224 - imageUrl: thumbnail.toString(), 225 + imageUrl: resolveImageUrlOrEmpty(thumbnail), 225 226 ), 226 227 cacheConfiguration: BetterPlayerCacheConfiguration( 227 228 useCache: true,
+33 -16
lib/src/core/ui/widgets/image_content.dart
··· 3 3 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:spark/src/core/ui/foundation/colors.dart'; 6 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 6 7 import 'package:spark/src/features/feed/ui/widgets/images/image_carousel.dart'; 7 8 8 9 class ImageContent extends StatelessWidget { ··· 51 52 52 53 @override 53 54 Widget build(BuildContext context) { 55 + final resolvedImageUrl = imageUrls.isEmpty 56 + ? null 57 + : resolveImageUrlString(imageUrls.first, isFullsize: true); 58 + 54 59 return GestureDetector( 55 60 onTap: () => _showImageCarousel(context), 56 61 child: ClipRRect( ··· 62 67 child: Stack( 63 68 fit: StackFit.expand, 64 69 children: [ 65 - CachedNetworkImage( 66 - fadeInDuration: Duration.zero, 67 - imageUrl: imageUrls.first, 68 - fit: BoxFit.cover, 69 - placeholder: (context, url) => Container( 70 - color: Colors.grey[850]?.withValues(alpha: 128), 71 - child: const Center( 72 - child: SizedBox( 73 - width: 20, 74 - height: 20, 75 - child: CircularProgressIndicator( 76 - strokeWidth: 2, 77 - color: Colors.white54, 70 + if (resolvedImageUrl != null) 71 + CachedNetworkImage( 72 + fadeInDuration: Duration.zero, 73 + imageUrl: resolvedImageUrl, 74 + fit: BoxFit.cover, 75 + placeholder: (context, url) => Container( 76 + color: Colors.grey[850]?.withValues(alpha: 128), 77 + child: const Center( 78 + child: SizedBox( 79 + width: 20, 80 + height: 20, 81 + child: CircularProgressIndicator( 82 + strokeWidth: 2, 83 + color: Colors.white54, 84 + ), 85 + ), 86 + ), 87 + ), 88 + errorWidget: (context, url, error) => ColoredBox( 89 + color: AppColors.darkPurple.withValues(alpha: 26), 90 + child: const Center( 91 + child: Icon( 92 + FluentIcons.image_off_24_regular, 93 + size: 24, 94 + color: Colors.white70, 78 95 ), 79 96 ), 80 97 ), 81 - ), 82 - errorWidget: (context, url, error) => ColoredBox( 98 + ) 99 + else 100 + ColoredBox( 83 101 color: AppColors.darkPurple.withValues(alpha: 26), 84 102 child: const Center( 85 103 child: Icon( ··· 89 107 ), 90 108 ), 91 109 ), 92 - ), 93 110 94 111 if (imageUrls.length > 1) 95 112 Positioned(
+4 -2
lib/src/core/ui/widgets/user_avatar.dart
··· 1 1 import 'package:cached_network_image/cached_network_image.dart'; 2 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 3 import 'package:flutter/material.dart'; 4 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 4 5 5 6 /// A customizable user avatar with fallback options when no image is available 6 7 class UserAvatar extends StatelessWidget { ··· 26 27 Widget build(BuildContext context) { 27 28 final theme = Theme.of(context); 28 29 final colorScheme = theme.colorScheme; 30 + final resolvedImageUrl = resolveImageUrlString(imageUrl); 29 31 30 32 final effectiveBorderColor = borderColor ?? colorScheme.outline; 31 33 final effectiveBackgroundColor = backgroundColor ?? colorScheme.primary; ··· 33 35 fallbackTextColor ?? colorScheme.onPrimary; 34 36 35 37 // If no image URL is provided, show fallback avatar 36 - if (imageUrl.isEmpty) { 38 + if (resolvedImageUrl == null) { 37 39 return Container( 38 40 width: size, 39 41 height: size, ··· 75 77 clipBehavior: Clip.antiAlias, 76 78 child: CachedNetworkImage( 77 79 fadeInDuration: Duration.zero, 78 - imageUrl: imageUrl, 80 + imageUrl: resolvedImageUrl, 79 81 fit: BoxFit.cover, 80 82 placeholder: (context, url) => ColoredBox( 81 83 color: effectiveBackgroundColor,
+49
lib/src/core/utils/image_url_resolver.dart
··· 1 + final RegExp _atUriPattern = RegExp(r'^at://([^/]+)/([^/]+)/(.+)$'); 2 + 3 + String? resolveImageUrlString(String? raw, {bool isFullsize = false}) { 4 + final candidate = raw?.trim(); 5 + if (candidate == null || candidate.isEmpty || candidate == 'null') { 6 + return null; 7 + } 8 + 9 + if (candidate.startsWith('//')) { 10 + return 'https:$candidate'; 11 + } 12 + 13 + final parsed = Uri.tryParse(candidate); 14 + final scheme = parsed?.scheme.toLowerCase(); 15 + if (scheme == 'http' || scheme == 'https') { 16 + return parsed.toString(); 17 + } 18 + 19 + final match = _atUriPattern.firstMatch(candidate); 20 + if (match == null) { 21 + return null; 22 + } 23 + 24 + final did = match.group(1)!; 25 + final collection = match.group(2)!; 26 + final rkey = match.group(3)!; 27 + 28 + if (collection != 'blob') { 29 + return null; 30 + } 31 + 32 + final variant = isFullsize ? 'feed_fullsize' : 'feed_thumbnail'; 33 + return 'https://cdn.bsky.app/img/$variant/plain/$did/$rkey@jpeg'; 34 + } 35 + 36 + String? resolveImageUrlObject(Object? raw, {bool isFullsize = false}) { 37 + final candidate = switch (raw) { 38 + null => null, 39 + String value => value, 40 + Uri value => value.toString(), 41 + _ => raw.toString(), 42 + }; 43 + 44 + return resolveImageUrlString(candidate, isFullsize: isFullsize); 45 + } 46 + 47 + String resolveImageUrlOrEmpty(Object? raw, {bool isFullsize = false}) { 48 + return resolveImageUrlObject(raw, isFullsize: isFullsize) ?? ''; 49 + }
+2 -1
lib/src/features/comments/ui/widgets/comment_item.dart
··· 15 15 import 'package:spark/src/core/ui/widgets/options_panel.dart'; 16 16 import 'package:spark/src/core/ui/widgets/report_dialog.dart'; 17 17 import 'package:spark/src/core/ui/widgets/user_avatar.dart'; 18 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 18 19 import 'package:spark/src/core/utils/utils.dart'; 19 20 import 'package:spark/src/features/comments/providers/comment_provider.dart'; 20 21 import 'package:spark/src/features/comments/providers/comment_state.dart'; ··· 384 385 @override 385 386 Widget build(BuildContext context) { 386 387 return UserAvatar( 387 - imageUrl: widget.thread.post.author.avatar.toString(), 388 + imageUrl: resolveImageUrlObject(widget.thread.post.author.avatar) ?? '', 388 389 username: widget.thread.post.author.handle, 389 390 size: 36, 390 391 );
+3 -1
lib/src/features/feed/ui/widgets/post/post_overlay.dart
··· 4 4 import 'package:spark/src/core/design_system/components/molecules/known_interactions_bar.dart'; 5 5 import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 6 6 import 'package:spark/src/core/utils/label_utils.dart'; 7 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 7 8 import 'package:spark/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart'; 8 9 import 'package:spark/src/features/feed/ui/widgets/post/info_bar.dart'; 9 10 import 'package:spark/src/features/settings/providers/preferences_provider.dart'; ··· 124 125 commentCount: '${post.replyCount ?? 0}', 125 126 shareCount: '${post.repostCount ?? 0}', 126 127 isLiked: isLiked, 127 - profileImageUrl: post.author.avatar.toString(), 128 + profileImageUrl: 129 + resolveImageUrlObject(post.author.avatar) ?? '', 128 130 isImage: 129 131 post.media is MediaViewImages || 130 132 post.media is MediaViewBskyImages,
+4 -2
lib/src/features/home/ui/pages/main_page.dart
··· 8 8 import 'package:spark/src/core/notifications/push_notification_service.dart'; 9 9 import 'package:spark/src/core/routing/app_router.dart'; 10 10 import 'package:spark/src/core/ui/theme/data/models/app_theme.dart'; 11 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 11 12 import 'package:spark/src/features/auth/providers/auth_providers.dart'; 12 13 import 'package:spark/src/features/feed/providers/feed_refresh_trigger_provider.dart'; 13 14 import 'package:spark/src/features/home/providers/navigation_provider.dart'; ··· 96 97 final profileAsync = userDid != null 97 98 ? ref.watch(profileProvider(did: userDid)) 98 99 : null; 99 - final userAvatar = profileAsync?.asData?.value.profile?.avatar 100 - ?.toString(); 100 + final userAvatar = resolveImageUrlObject( 101 + profileAsync?.asData?.value.profile?.avatar, 102 + ); 101 103 102 104 final avatarProvider = userAvatar != null && userAvatar.isNotEmpty 103 105 ? CachedNetworkImageProvider(userAvatar)
+3 -2
lib/src/features/messages/ui/pages/messages_page.dart
··· 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 5 import 'package:get_it/get_it.dart'; 6 6 import 'package:spark/src/core/design_system/templates/chat_list_page_template.dart'; 7 + import 'package:spark/src/core/l10n/app_localizations.dart'; 7 8 import 'package:spark/src/core/routing/app_router.dart'; 9 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 8 10 import 'package:spark/src/core/utils/logging/logging.dart'; 9 - import 'package:spark/src/core/l10n/app_localizations.dart'; 10 11 import 'package:spark/src/features/messages/providers/conversations_provider.dart'; 11 12 12 13 @RoutePage() ··· 62 63 otherUserDid: profile.did, 63 64 otherUserHandle: profile.handle, 64 65 otherUserDisplayName: profile.displayName, 65 - otherUserAvatar: profile.avatar.toString(), 66 + otherUserAvatar: resolveImageUrlObject(profile.avatar), 66 67 ), 67 68 ); 68 69 },
+12 -11
lib/src/features/notifications/ui/widgets/notification_item.dart
··· 12 12 import 'package:spark/src/core/routing/app_router.dart'; 13 13 import 'package:spark/src/core/ui/foundation/colors.dart'; 14 14 import 'package:spark/src/core/ui/widgets/user_avatar.dart'; 15 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 15 16 import 'package:spark/src/features/messages/ui/pages/chat_page.dart'; 16 17 import 'package:spark/src/features/notifications/models/grouped_notification.dart'; 17 18 ··· 332 333 case 'so.sprk.media.image#view': 333 334 final thumb = media['thumb']; 334 335 if (thumb != null) { 335 - return thumb is String ? thumb : thumb.toString(); 336 + return resolveImageUrlObject(thumb); 336 337 } 337 338 final fullsize = media['fullsize']; 338 339 if (fullsize != null) { 339 - return fullsize is String ? fullsize : fullsize.toString(); 340 + return resolveImageUrlObject(fullsize, isFullsize: true); 340 341 } 341 342 342 343 // Multiple images - get first one ··· 347 348 if (firstImage != null) { 348 349 final thumb = firstImage['thumb']; 349 350 if (thumb != null) { 350 - return thumb is String ? thumb : thumb.toString(); 351 + return resolveImageUrlObject(thumb); 351 352 } 352 353 final fullsize = firstImage['fullsize']; 353 354 if (fullsize != null) { 354 - return fullsize is String ? fullsize : fullsize.toString(); 355 + return resolveImageUrlObject(fullsize, isFullsize: true); 355 356 } 356 357 } 357 358 } ··· 361 362 case 'app.bsky.embed.video#view': 362 363 final thumbnail = media['thumbnail']; 363 364 if (thumbnail != null) { 364 - return thumbnail is String ? thumbnail : thumbnail.toString(); 365 + return resolveImageUrlObject(thumbnail); 365 366 } 366 367 367 368 // Bluesky images ··· 372 373 if (firstImage != null) { 373 374 final thumb = firstImage['thumb']; 374 375 if (thumb != null) { 375 - return thumb is String ? thumb : thumb.toString(); 376 + return resolveImageUrlObject(thumb); 376 377 } 377 378 final fullsize = firstImage['fullsize']; 378 379 if (fullsize != null) { 379 - return fullsize is String ? fullsize : fullsize.toString(); 380 + return resolveImageUrlObject(fullsize, isFullsize: true); 380 381 } 381 382 } 382 383 } ··· 393 394 if (firstImage != null) { 394 395 final thumb = firstImage['thumb']; 395 396 if (thumb != null) { 396 - return thumb is String ? thumb : thumb.toString(); 397 + return resolveImageUrlObject(thumb); 397 398 } 398 399 } 399 400 } 400 401 } else if (nestedType == 'app.bsky.embed.video#view') { 401 402 final thumbnail = nestedMedia['thumbnail']; 402 403 if (thumbnail != null) { 403 - return thumbnail is String ? thumbnail : thumbnail.toString(); 404 + return resolveImageUrlObject(thumbnail); 404 405 } 405 406 } 406 407 } ··· 437 438 if (authors.length == 1) { 438 439 // Single avatar 439 440 final author = authors[0].author; 440 - final avatarUrl = author.avatar?.toString() ?? ''; 441 + final avatarUrl = resolveImageUrlObject(author.avatar) ?? ''; 441 442 final username = author.displayName ?? author.handle; 442 443 final handleHash = author.handle.hashCode; 443 444 ··· 467 468 ...authors.asMap().entries.map((entry) { 468 469 final index = entry.key; 469 470 final author = entry.value.author; 470 - final avatarUrl = author.avatar?.toString() ?? ''; 471 + final avatarUrl = resolveImageUrlObject(author.avatar) ?? ''; 471 472 final username = author.displayName ?? author.handle; 472 473 final handleHash = author.handle.hashCode; 473 474
+5 -3
lib/src/features/posting/ui/widgets/mention_input_field.dart
··· 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:spark/src/core/design_system/components/molecules/input_field.dart'; 4 4 import 'package:spark/src/core/design_system/tokens/constants.dart'; 5 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 5 6 import 'package:spark/src/core/utils/text_formatter.dart'; 6 7 import 'package:spark/src/features/posting/models/mention.dart'; 7 8 import 'package:spark/src/features/posting/models/mention_controller.dart'; ··· 360 361 itemCount: typeaheadState.results.length, 361 362 itemBuilder: (context, index) { 362 363 final actor = typeaheadState.results[index]; 364 + final avatarUrl = resolveImageUrlObject(actor.avatar); 363 365 return ListTile( 364 366 dense: true, 365 367 leading: CircleAvatar( 366 368 radius: 16, 367 - backgroundImage: actor.avatar != null 368 - ? NetworkImage(actor.avatar.toString()) 369 + backgroundImage: avatarUrl != null 370 + ? NetworkImage(avatarUrl) 369 371 : null, 370 - child: actor.avatar == null 372 + child: avatarUrl == null 371 373 ? const Icon(Icons.person, size: 16) 372 374 : null, 373 375 ),
+4 -4
lib/src/features/search/ui/pages/search_page.dart
··· 9 9 import 'package:spark/src/core/l10n/app_localizations.dart'; 10 10 import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 11 11 import 'package:spark/src/core/routing/app_router.dart'; 12 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 12 13 import 'package:spark/src/features/search/providers/actor_typeahead_provider.dart'; 13 14 import 'package:spark/src/features/search/providers/actor_typeahead_state.dart'; 14 15 import 'package:spark/src/features/search/providers/post_search_provider.dart'; ··· 222 223 separatorBuilder: (context, index) => const Divider(height: 1), 223 224 itemBuilder: (context, index) { 224 225 final actor = state.results[index]; 226 + final avatarUrl = resolveImageUrlObject(actor.avatar); 225 227 226 228 return ListTile( 227 229 onTap: () => onSuggestionSelected(actor), 228 230 contentPadding: const EdgeInsets.symmetric(vertical: 4), 229 231 leading: CircleAvatar( 230 232 radius: 18, 231 - backgroundImage: actor.avatar != null 232 - ? NetworkImage(actor.avatar.toString()) 233 - : null, 234 - child: actor.avatar == null ? const Icon(Icons.person) : null, 233 + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, 234 + child: avatarUrl == null ? const Icon(Icons.person) : null, 235 235 ), 236 236 title: Text(actor.displayName ?? actor.handle), 237 237 subtitle: Text('@${actor.handle}'),
+2 -1
lib/src/features/settings/ui/pages/labeler_label_settings_page.dart
··· 12 12 import 'package:spark/src/core/network/atproto/data/repositories/actor_repository.dart'; 13 13 import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 14 14 import 'package:spark/src/core/l10n/app_localizations.dart'; 15 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 15 16 import 'package:spark/src/core/utils/logging/logging.dart'; 16 17 import 'package:spark/src/features/settings/providers/settings_provider.dart'; 17 18 import 'package:spark/src/features/settings/ui/widgets/widgets.dart'; ··· 570 571 borderRadius: BorderRadius.circular(100), 571 572 child: profile.avatar != null 572 573 ? CachedNetworkImage( 573 - imageUrl: profile.avatar!.toString(), 574 + imageUrl: resolveImageUrlOrEmpty(profile.avatar), 574 575 width: 36, 575 576 height: 36, 576 577 fit: BoxFit.cover,
+2 -1
lib/src/features/settings/ui/pages/labeler_management_page.dart
··· 10 10 import 'package:spark/src/core/network/atproto/data/repositories/actor_repository.dart'; 11 11 import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; 12 12 import 'package:spark/src/core/routing/app_router.dart'; 13 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 13 14 import 'package:spark/src/core/utils/logging/logging.dart'; 14 15 import 'package:spark/src/features/settings/providers/settings_provider.dart'; 15 16 ··· 398 399 borderRadius: BorderRadius.circular(100), 399 400 child: profile.avatar != null 400 401 ? CachedNetworkImage( 401 - imageUrl: profile.avatar!.toString(), 402 + imageUrl: resolveImageUrlOrEmpty(profile.avatar), 402 403 width: 36, 403 404 height: 36, 404 405 fit: BoxFit.cover,
+14 -6
lib/src/features/stories/ui/pages/author_stories_page.dart
··· 8 8 import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; 9 9 import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 10 10 import 'package:spark/src/core/routing/app_router.dart'; 11 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 11 12 import 'package:spark/src/features/stories/ui/pages/story_page.dart'; 12 13 13 14 @RoutePage() ··· 253 254 return Scaffold(body: Center(child: Text(l10n.emptyNoStories))); 254 255 } 255 256 257 + final avatarUrl = resolveImageUrlObject(widget.author.avatar); 258 + 256 259 return Scaffold( 257 260 backgroundColor: Colors.black, 258 261 body: SafeArea( ··· 361 364 shape: BoxShape.circle, 362 365 ), 363 366 child: ClipOval( 364 - child: CachedNetworkImage( 365 - imageUrl: widget.author.avatar.toString(), 366 - fit: BoxFit.cover, 367 - errorWidget: (context, url, error) => 368 - const Icon( 367 + child: avatarUrl != null 368 + ? CachedNetworkImage( 369 + imageUrl: avatarUrl, 370 + fit: BoxFit.cover, 371 + errorWidget: (context, url, error) => 372 + const Icon( 373 + Icons.person, 374 + color: Colors.white, 375 + ), 376 + ) 377 + : const Icon( 369 378 Icons.person, 370 379 color: Colors.white, 371 380 ), 372 - ), 373 381 ), 374 382 ), 375 383 ),
+8 -3
lib/src/features/stories/ui/pages/story_manager_page.dart
··· 5 5 import 'package:spark/src/core/l10n/app_localizations.dart'; 6 6 import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 7 7 import 'package:spark/src/core/routing/app_router.dart'; 8 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 8 9 import 'package:spark/src/features/stories/providers/story_auto_delete_provider.dart'; 9 10 import 'package:spark/src/features/stories/providers/story_manager_provider.dart'; 10 11 ··· 99 100 ? '${age.inMinutes}m' 100 101 : 'now'; 101 102 final thumbUrl = switch (story.media) { 102 - MediaViewVideo(:final thumbnail) => thumbnail.toString(), 103 - MediaViewImage(:final image) => image.thumb.toString(), 104 - _ => story.author.avatar.toString(), 103 + MediaViewVideo(:final thumbnail) => resolveImageUrlOrEmpty( 104 + thumbnail, 105 + ), 106 + MediaViewImage(:final image) => resolveImageUrlOrEmpty( 107 + image.thumb, 108 + ), 109 + _ => resolveImageUrlOrEmpty(story.author.avatar), 105 110 }; 106 111 return Material( 107 112 color: Colors.transparent,
+6 -2
lib/src/features/stories/ui/pages/story_page.dart
··· 8 8 import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; 9 9 import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; 10 10 import 'package:spark/src/core/routing/app_router.dart'; 11 + import 'package:spark/src/core/utils/image_url_resolver.dart'; 11 12 import 'package:video_player/video_player.dart'; 12 13 13 14 @RoutePage() ··· 144 145 145 146 String _getImageUrl(StoryView story) { 146 147 return switch (story.media) { 147 - MediaViewImage(:final image) => image.fullsize.toString(), 148 - _ => widget.story.author.avatar.toString(), 148 + MediaViewImage(:final image) => resolveImageUrlOrEmpty( 149 + image.fullsize, 150 + isFullsize: true, 151 + ), 152 + _ => resolveImageUrlOrEmpty(widget.story.author.avatar), 149 153 }; 150 154 } 151 155