diff --git a/lib/src/core/design_system/components/molecules/feed_card.dart b/lib/src/core/design_system/components/molecules/feed_card.dart index 4d168b07..e64eb102 100644 --- a/lib/src/core/design_system/components/molecules/feed_card.dart +++ b/lib/src/core/design_system/components/molecules/feed_card.dart @@ -8,6 +8,7 @@ import 'package:spark/src/core/design_system/tokens/shapes.dart'; import 'package:spark/src/core/design_system/tokens/typography.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; class FeedCard extends StatelessWidget { const FeedCard({ @@ -55,10 +56,7 @@ class FeedCard extends StatelessWidget { String? get _description => generator?.description; String get _avatarUrl { - if (generator?.avatar != null) { - return generator!.avatar.toString(); - } - return ''; + return resolveImageUrlObject(generator?.avatar) ?? ''; } bool get _isPrimaryAction => !isAdded || !isPinned; @@ -248,8 +246,9 @@ class _FeedAvatar extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; + final resolvedImageUrl = resolveImageUrlString(imageUrl); - if (imageUrl.isEmpty) { + if (resolvedImageUrl == null) { return _FallbackAvatar(isDark: isDark); } @@ -257,7 +256,7 @@ class _FeedAvatar extends StatelessWidget { borderRadius: BorderRadius.circular(8), child: CachedNetworkImage( fadeInDuration: Duration.zero, - imageUrl: imageUrl, + imageUrl: resolvedImageUrl, width: 36, height: 36, fit: BoxFit.cover, diff --git a/lib/src/core/design_system/components/molecules/post_tile.dart b/lib/src/core/design_system/components/molecules/post_tile.dart index c30066b9..93f95d7a 100644 --- a/lib/src/core/design_system/components/molecules/post_tile.dart +++ b/lib/src/core/design_system/components/molecules/post_tile.dart @@ -7,6 +7,7 @@ import 'package:spark/src/core/design_system/components/atoms/icons.dart'; import 'package:spark/src/core/design_system/tokens/colors.dart'; import 'package:spark/src/core/design_system/tokens/shapes.dart'; import 'package:spark/src/core/design_system/tokens/typography.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; class PostTile extends StatelessWidget { final String thumbnailUrl; @@ -31,6 +32,7 @@ class PostTile extends StatelessWidget { @override Widget build(BuildContext context) { + final resolvedThumbnailUrl = resolveImageUrlString(thumbnailUrl); // Squircle shape from design tokens final BorderRadiusGeometry radius = BorderRadius.circular( AppShapes.squircleRadius, @@ -55,12 +57,17 @@ class PostTile extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - if (nsfwBlur) + if (resolvedThumbnailUrl == null) + const ColoredBox( + color: AppColors.grey800, + child: Icon(Icons.broken_image, color: AppColors.grey400), + ) + else if (nsfwBlur) ImageFiltered( imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: CachedNetworkImage( fadeInDuration: Duration.zero, - imageUrl: thumbnailUrl, + imageUrl: resolvedThumbnailUrl, fit: BoxFit.cover, placeholder: (context, url) => const ColoredBox(color: AppColors.grey800), @@ -73,7 +80,7 @@ class PostTile extends StatelessWidget { else CachedNetworkImage( fadeInDuration: Duration.zero, - imageUrl: thumbnailUrl, + imageUrl: resolvedThumbnailUrl, fit: BoxFit.cover, placeholder: (context, url) => const ColoredBox(color: AppColors.grey800), diff --git a/lib/src/core/design_system/components/molecules/profile_avatar.dart b/lib/src/core/design_system/components/molecules/profile_avatar.dart index 1983b456..ac9bd13a 100644 --- a/lib/src/core/design_system/components/molecules/profile_avatar.dart +++ b/lib/src/core/design_system/components/molecules/profile_avatar.dart @@ -3,6 +3,7 @@ import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; import 'package:spark/src/core/design_system/tokens/gradients.dart'; import 'package:spark/src/core/ui/foundation/colors.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; class ProfileAvatar extends StatelessWidget { const ProfileAvatar({ @@ -111,11 +112,12 @@ class ProfileAvatar extends StatelessWidget { required bool isDarkMode, required double avatarSize, }) { - if (avatarUrl != null && avatarUrl!.isNotEmpty) { + final resolvedAvatarUrl = resolveImageUrlString(avatarUrl); + if (resolvedAvatarUrl != null) { return ClipOval( child: CachedNetworkImage( fadeInDuration: Duration.zero, - imageUrl: avatarUrl!, + imageUrl: resolvedAvatarUrl, width: avatarSize, height: avatarSize, fit: BoxFit.cover, diff --git a/lib/src/core/design_system/components/molecules/settings_feed_card.dart b/lib/src/core/design_system/components/molecules/settings_feed_card.dart index 4f1a5c22..671461e2 100644 --- a/lib/src/core/design_system/components/molecules/settings_feed_card.dart +++ b/lib/src/core/design_system/components/molecules/settings_feed_card.dart @@ -7,6 +7,7 @@ import 'package:spark/src/core/design_system/tokens/shapes.dart'; import 'package:spark/src/core/design_system/tokens/typography.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; enum SettingsFeedCardMode { display, edit } @@ -60,10 +61,7 @@ class SettingsFeedCard extends StatelessWidget { } String get _avatarUrl { - if (generator?.avatar != null) { - return generator!.avatar.toString(); - } - return ''; + return resolveImageUrlObject(generator?.avatar) ?? ''; } @override diff --git a/lib/src/core/design_system/components/molecules/story_circle.dart b/lib/src/core/design_system/components/molecules/story_circle.dart index 090463cc..5a0e17bb 100644 --- a/lib/src/core/design_system/components/molecules/story_circle.dart +++ b/lib/src/core/design_system/components/molecules/story_circle.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:spark/src/core/design_system/tokens/gradients.dart'; import 'package:spark/src/core/design_system/tokens/typography.dart'; import 'package:spark/src/core/ui/foundation/colors.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; enum StoryType { story, live, cf, create } @@ -84,6 +85,7 @@ class StoryCircle extends StatelessWidget { Widget build(BuildContext context) { final hasStoryRing = type != StoryType.create; final ringColor = _getRingColor(); + final resolvedImageUrl = resolveImageUrlString(imageUrl); return SizedBox( width: _widgetWidth, @@ -111,10 +113,10 @@ class StoryCircle extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(_ringGap), child: ClipOval( - child: imageUrl.isNotEmpty + child: resolvedImageUrl != null ? CachedNetworkImage( fadeInDuration: Duration.zero, - imageUrl: imageUrl, + imageUrl: resolvedImageUrl, width: _imageSize, height: _imageSize, fit: BoxFit.cover, diff --git a/lib/src/core/network/atproto/data/models/feed_models.dart b/lib/src/core/network/atproto/data/models/feed_models.dart index 3777475d..30078c89 100644 --- a/lib/src/core/network/atproto/data/models/feed_models.dart +++ b/lib/src/core/network/atproto/data/models/feed_models.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:spark/src/core/network/atproto/data/adapters/bsky/feed_adapter.dart'; import 'package:spark/src/core/network/atproto/data/models/models.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/core/utils/uri_converter.dart'; part 'feed_models.freezed.dart'; @@ -386,38 +387,7 @@ abstract class PostView with _$PostView { /// Resolves AT Protocol blob URLs to HTTP URLs for display String _resolveAtUriToHttpUrl(Uri uri, {bool isFullsize = false}) { - final uriString = uri.toString(); - - // If it's already an HTTP URL, return as is - if (uriString.startsWith('http://') || uriString.startsWith('https://')) { - return uriString; - } - - // If it's an AT Protocol blob URL, convert to Bluesky CDN URL - if (uriString.startsWith('at://')) { - // Parse AT URI format: at://did/collection/rkey - final match = RegExp( - r'^at://([^/]+)/([^/]+)/(.+)$', - ).firstMatch(uriString); - if (match != null) { - final did = match.group(1)!; - final collection = match.group(2)!; - final rkey = match.group(3)!; - - // For blob collections, use Bluesky's CDN - if (collection == 'blob') { - if (isFullsize) { - return 'https://cdn.bsky.app/img/feed_fullsize/plain/$did/$rkey@jpeg'; - } else { - return 'https://cdn.bsky.app/img/feed_thumbnail/plain/$did/$rkey@jpeg'; - } - } - } - } - - // Return empty string for unrecognized URI schemes (e.g., file://) - // to prevent invalid URLs from being passed to image loaders - return ''; + return resolveImageUrlOrEmpty(uri, isFullsize: isFullsize); } String get videoUrl { @@ -446,12 +416,14 @@ abstract class PostView with _$PostView { List get imageUrls { final mediaToCheck = displayMedia; - final List urls; + final List urls; switch (mediaToCheck) { case MediaViewImage(:final image): - urls = [image.fullsize.toString()]; + urls = [resolveImageUrlObject(image.fullsize, isFullsize: true)]; case MediaViewImages(:final images): - urls = images.map((img) => img.fullsize.toString()).toList(); + urls = images + .map((img) => resolveImageUrlObject(img.fullsize, isFullsize: true)) + .toList(); case MediaViewBskyImages(:final images): urls = images .map( @@ -462,9 +434,14 @@ abstract class PostView with _$PostView { // Handle nested media in record with media switch (media) { case MediaViewImage(:final image): - urls = [image.fullsize.toString()]; + urls = [resolveImageUrlObject(image.fullsize, isFullsize: true)]; case MediaViewImages(:final images): - urls = images.map((img) => img.fullsize.toString()).toList(); + urls = images + .map( + (img) => + resolveImageUrlObject(img.fullsize, isFullsize: true), + ) + .toList(); case MediaViewBskyImages(:final images): urls = images .map( @@ -478,36 +455,33 @@ abstract class PostView with _$PostView { case _: urls = []; } - // Filter out invalid URLs (must be http/https) - return urls - .where((url) => url.startsWith('http://') || url.startsWith('https://')) - .toList(); + return urls.whereType().toList(); } String get thumbnailUrl { final mediaToCheck = displayMedia; switch (mediaToCheck) { case MediaViewVideo(:final thumbnail): - return thumbnail.toString(); + return resolveImageUrlOrEmpty(thumbnail); case MediaViewBskyVideo(:final thumbnail): return _resolveAtUriToHttpUrl(thumbnail); case MediaViewImage(:final image): - return image.thumb.toString(); + return resolveImageUrlOrEmpty(image.thumb); case MediaViewImages(:final images): - return images.first.thumb.toString(); + return resolveImageUrlOrEmpty(images.first.thumb); case MediaViewBskyImages(:final images): return _resolveAtUriToHttpUrl(images.first.thumb); case MediaViewBskyRecordWithMedia(:final media): // Handle nested media in record with media switch (media) { case MediaViewVideo(:final thumbnail): - return thumbnail.toString(); + return resolveImageUrlOrEmpty(thumbnail); case MediaViewBskyVideo(:final thumbnail): return _resolveAtUriToHttpUrl(thumbnail); case MediaViewImage(:final image): - return image.thumb.toString(); + return resolveImageUrlOrEmpty(image.thumb); case MediaViewImages(:final images): - return images.first.thumb.toString(); + return resolveImageUrlOrEmpty(images.first.thumb); case MediaViewBskyImages(:final images): return _resolveAtUriToHttpUrl(images.first.thumb); case _: @@ -848,11 +822,23 @@ sealed class ThreadPost with _$ThreadPost { ThreadPostView(:final post) => post.imageUrls, ThreadReplyView(:final reply) => switch (reply.hydratedMedia) { // Replies/comments only support a single image (EmbedViewMediaImage) - MediaViewImage(:final image) => [image.fullsize.toString()], + MediaViewImage(:final image) => [ + resolveImageUrlObject(image.fullsize, isFullsize: true), + ].whereType().toList(), MediaViewImages(:final images) => - images.map((img) => img.fullsize.toString()).toList(), + images + .map( + (img) => resolveImageUrlOrEmpty(img.fullsize, isFullsize: true), + ) + .where((url) => url.isNotEmpty) + .toList(), MediaViewBskyImages(:final images) => - images.map((img) => img.fullsize.toString()).toList(), + images + .map( + (img) => resolveImageUrlOrEmpty(img.fullsize, isFullsize: true), + ) + .where((url) => url.isNotEmpty) + .toList(), _ => [], }, }; diff --git a/lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart b/lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart index c113e800..ed90e03e 100644 --- a/lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart +++ b/lib/src/core/pro_image_editor/ui/widgets/story_mention_picker_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/features/search/providers/actor_typeahead_provider.dart'; import 'package:spark/src/features/search/providers/actor_typeahead_state.dart'; @@ -163,14 +164,13 @@ class _ResultsList extends StatelessWidget { separatorBuilder: (_, _) => const Divider(color: Color(0x1FFFFFFF)), itemBuilder: (context, index) { final actor = typeaheadState.results[index]; + final avatarUrl = resolveImageUrlObject(actor.avatar); return ListTile( contentPadding: EdgeInsets.zero, leading: CircleAvatar( backgroundColor: const Color(0x1AFFFFFF), - backgroundImage: actor.avatar != null - ? NetworkImage(actor.avatar.toString()) - : null, - child: actor.avatar == null + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, + child: avatarUrl == null ? const Icon(Icons.person_outline, color: Colors.white) : null, ), diff --git a/lib/src/core/storage/cache/download_manager_impl.dart b/lib/src/core/storage/cache/download_manager_impl.dart index 619d0bb1..7cc36bf8 100644 --- a/lib/src/core/storage/cache/download_manager_impl.dart +++ b/lib/src/core/storage/cache/download_manager_impl.dart @@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart'; import 'package:pool/pool.dart'; import 'package:spark/src/core/network/atproto/data/models/models.dart'; import 'package:spark/src/core/storage/cache/download_manager_interface.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/core/utils/logging/logging.dart'; import 'package:spark/src/features/feed/providers/feed_state.dart'; @@ -221,7 +222,7 @@ class DownloadManagerImpl implements DownloadManagerInterface { task.post.videoUrl, placeholder: CachedNetworkImage( fadeInDuration: Duration.zero, - imageUrl: thumbnail.toString(), + imageUrl: resolveImageUrlOrEmpty(thumbnail), ), cacheConfiguration: BetterPlayerCacheConfiguration( useCache: true, diff --git a/lib/src/core/ui/widgets/image_content.dart b/lib/src/core/ui/widgets/image_content.dart index 0d292747..f83a7ac9 100644 --- a/lib/src/core/ui/widgets/image_content.dart +++ b/lib/src/core/ui/widgets/image_content.dart @@ -3,6 +3,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; import 'package:spark/src/core/ui/foundation/colors.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/features/feed/ui/widgets/images/image_carousel.dart'; class ImageContent extends StatelessWidget { @@ -51,6 +52,10 @@ class ImageContent extends StatelessWidget { @override Widget build(BuildContext context) { + final resolvedImageUrl = imageUrls.isEmpty + ? null + : resolveImageUrlString(imageUrls.first, isFullsize: true); + return GestureDetector( onTap: () => _showImageCarousel(context), child: ClipRRect( @@ -62,24 +67,37 @@ class ImageContent extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - CachedNetworkImage( - fadeInDuration: Duration.zero, - imageUrl: imageUrls.first, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Colors.grey[850]?.withValues(alpha: 128), - child: const Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white54, + if (resolvedImageUrl != null) + CachedNetworkImage( + fadeInDuration: Duration.zero, + imageUrl: resolvedImageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Colors.grey[850]?.withValues(alpha: 128), + child: const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white54, + ), ), ), ), - ), - errorWidget: (context, url, error) => ColoredBox( + errorWidget: (context, url, error) => ColoredBox( + color: AppColors.darkPurple.withValues(alpha: 26), + child: const Center( + child: Icon( + FluentIcons.image_off_24_regular, + size: 24, + color: Colors.white70, + ), + ), + ), + ) + else + ColoredBox( color: AppColors.darkPurple.withValues(alpha: 26), child: const Center( child: Icon( @@ -89,7 +107,6 @@ class ImageContent extends StatelessWidget { ), ), ), - ), if (imageUrls.length > 1) Positioned( diff --git a/lib/src/core/ui/widgets/user_avatar.dart b/lib/src/core/ui/widgets/user_avatar.dart index d6dfdf86..43d082b0 100644 --- a/lib/src/core/ui/widgets/user_avatar.dart +++ b/lib/src/core/ui/widgets/user_avatar.dart @@ -1,6 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:fluentui_system_icons/fluentui_system_icons.dart'; import 'package:flutter/material.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; /// A customizable user avatar with fallback options when no image is available class UserAvatar extends StatelessWidget { @@ -26,6 +27,7 @@ class UserAvatar extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final resolvedImageUrl = resolveImageUrlString(imageUrl); final effectiveBorderColor = borderColor ?? colorScheme.outline; final effectiveBackgroundColor = backgroundColor ?? colorScheme.primary; @@ -33,7 +35,7 @@ class UserAvatar extends StatelessWidget { fallbackTextColor ?? colorScheme.onPrimary; // If no image URL is provided, show fallback avatar - if (imageUrl.isEmpty) { + if (resolvedImageUrl == null) { return Container( width: size, height: size, @@ -75,7 +77,7 @@ class UserAvatar extends StatelessWidget { clipBehavior: Clip.antiAlias, child: CachedNetworkImage( fadeInDuration: Duration.zero, - imageUrl: imageUrl, + imageUrl: resolvedImageUrl, fit: BoxFit.cover, placeholder: (context, url) => ColoredBox( color: effectiveBackgroundColor, diff --git a/lib/src/core/utils/image_url_resolver.dart b/lib/src/core/utils/image_url_resolver.dart new file mode 100644 index 00000000..65280eb2 --- /dev/null +++ b/lib/src/core/utils/image_url_resolver.dart @@ -0,0 +1,49 @@ +final RegExp _atUriPattern = RegExp(r'^at://([^/]+)/([^/]+)/(.+)$'); + +String? resolveImageUrlString(String? raw, {bool isFullsize = false}) { + final candidate = raw?.trim(); + if (candidate == null || candidate.isEmpty || candidate == 'null') { + return null; + } + + if (candidate.startsWith('//')) { + return 'https:$candidate'; + } + + final parsed = Uri.tryParse(candidate); + final scheme = parsed?.scheme.toLowerCase(); + if (scheme == 'http' || scheme == 'https') { + return parsed.toString(); + } + + final match = _atUriPattern.firstMatch(candidate); + if (match == null) { + return null; + } + + final did = match.group(1)!; + final collection = match.group(2)!; + final rkey = match.group(3)!; + + if (collection != 'blob') { + return null; + } + + final variant = isFullsize ? 'feed_fullsize' : 'feed_thumbnail'; + return 'https://cdn.bsky.app/img/$variant/plain/$did/$rkey@jpeg'; +} + +String? resolveImageUrlObject(Object? raw, {bool isFullsize = false}) { + final candidate = switch (raw) { + null => null, + String value => value, + Uri value => value.toString(), + _ => raw.toString(), + }; + + return resolveImageUrlString(candidate, isFullsize: isFullsize); +} + +String resolveImageUrlOrEmpty(Object? raw, {bool isFullsize = false}) { + return resolveImageUrlObject(raw, isFullsize: isFullsize) ?? ''; +} diff --git a/lib/src/features/comments/ui/widgets/comment_item.dart b/lib/src/features/comments/ui/widgets/comment_item.dart index d9c6de4d..a30caba3 100644 --- a/lib/src/features/comments/ui/widgets/comment_item.dart +++ b/lib/src/features/comments/ui/widgets/comment_item.dart @@ -15,6 +15,7 @@ import 'package:spark/src/core/ui/widgets/image_content.dart'; import 'package:spark/src/core/ui/widgets/options_panel.dart'; import 'package:spark/src/core/ui/widgets/report_dialog.dart'; import 'package:spark/src/core/ui/widgets/user_avatar.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/core/utils/utils.dart'; import 'package:spark/src/features/comments/providers/comment_provider.dart'; import 'package:spark/src/features/comments/providers/comment_state.dart'; @@ -384,7 +385,7 @@ class _Avatar extends StatelessWidget { @override Widget build(BuildContext context) { return UserAvatar( - imageUrl: widget.thread.post.author.avatar.toString(), + imageUrl: resolveImageUrlObject(widget.thread.post.author.avatar) ?? '', username: widget.thread.post.author.handle, size: 36, ); diff --git a/lib/src/features/feed/ui/widgets/post/post_overlay.dart b/lib/src/features/feed/ui/widgets/post/post_overlay.dart index 6855a718..23593897 100644 --- a/lib/src/features/feed/ui/widgets/post/post_overlay.dart +++ b/lib/src/features/feed/ui/widgets/post/post_overlay.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/design_system/components/molecules/known_interactions_bar.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; import 'package:spark/src/core/utils/label_utils.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart'; import 'package:spark/src/features/feed/ui/widgets/post/info_bar.dart'; import 'package:spark/src/features/settings/providers/preferences_provider.dart'; @@ -124,7 +125,8 @@ class PostOverlay extends ConsumerWidget { commentCount: '${post.replyCount ?? 0}', shareCount: '${post.repostCount ?? 0}', isLiked: isLiked, - profileImageUrl: post.author.avatar.toString(), + profileImageUrl: + resolveImageUrlObject(post.author.avatar) ?? '', isImage: post.media is MediaViewImages || post.media is MediaViewBskyImages, diff --git a/lib/src/features/home/ui/pages/main_page.dart b/lib/src/features/home/ui/pages/main_page.dart index 3af4c6b8..77eebe14 100644 --- a/lib/src/features/home/ui/pages/main_page.dart +++ b/lib/src/features/home/ui/pages/main_page.dart @@ -8,6 +8,7 @@ import 'package:spark/src/core/design_system/components/organisms/bottom_nav_bar import 'package:spark/src/core/notifications/push_notification_service.dart'; import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/core/ui/theme/data/models/app_theme.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/features/auth/providers/auth_providers.dart'; import 'package:spark/src/features/feed/providers/feed_refresh_trigger_provider.dart'; import 'package:spark/src/features/home/providers/navigation_provider.dart'; @@ -96,8 +97,9 @@ class _MainPageState extends ConsumerState { final profileAsync = userDid != null ? ref.watch(profileProvider(did: userDid)) : null; - final userAvatar = profileAsync?.asData?.value.profile?.avatar - ?.toString(); + final userAvatar = resolveImageUrlObject( + profileAsync?.asData?.value.profile?.avatar, + ); final avatarProvider = userAvatar != null && userAvatar.isNotEmpty ? CachedNetworkImageProvider(userAvatar) diff --git a/lib/src/features/messages/ui/pages/messages_page.dart b/lib/src/features/messages/ui/pages/messages_page.dart index 3901fffa..c2acbbdf 100644 --- a/lib/src/features/messages/ui/pages/messages_page.dart +++ b/lib/src/features/messages/ui/pages/messages_page.dart @@ -4,9 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:spark/src/core/design_system/templates/chat_list_page_template.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/core/utils/logging/logging.dart'; -import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/features/messages/providers/conversations_provider.dart'; @RoutePage() @@ -62,7 +63,7 @@ class _MessagesPageState extends ConsumerState { otherUserDid: profile.did, otherUserHandle: profile.handle, otherUserDisplayName: profile.displayName, - otherUserAvatar: profile.avatar.toString(), + otherUserAvatar: resolveImageUrlObject(profile.avatar), ), ); }, diff --git a/lib/src/features/notifications/ui/widgets/notification_item.dart b/lib/src/features/notifications/ui/widgets/notification_item.dart index 0878e951..c6355696 100644 --- a/lib/src/features/notifications/ui/widgets/notification_item.dart +++ b/lib/src/features/notifications/ui/widgets/notification_item.dart @@ -12,6 +12,7 @@ import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/core/ui/foundation/colors.dart'; import 'package:spark/src/core/ui/widgets/user_avatar.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/features/messages/ui/pages/chat_page.dart'; import 'package:spark/src/features/notifications/models/grouped_notification.dart'; @@ -332,11 +333,11 @@ class _NotificationItemState extends ConsumerState { case 'so.sprk.media.image#view': final thumb = media['thumb']; if (thumb != null) { - return thumb is String ? thumb : thumb.toString(); + return resolveImageUrlObject(thumb); } final fullsize = media['fullsize']; if (fullsize != null) { - return fullsize is String ? fullsize : fullsize.toString(); + return resolveImageUrlObject(fullsize, isFullsize: true); } // Multiple images - get first one @@ -347,11 +348,11 @@ class _NotificationItemState extends ConsumerState { if (firstImage != null) { final thumb = firstImage['thumb']; if (thumb != null) { - return thumb is String ? thumb : thumb.toString(); + return resolveImageUrlObject(thumb); } final fullsize = firstImage['fullsize']; if (fullsize != null) { - return fullsize is String ? fullsize : fullsize.toString(); + return resolveImageUrlObject(fullsize, isFullsize: true); } } } @@ -361,7 +362,7 @@ class _NotificationItemState extends ConsumerState { case 'app.bsky.embed.video#view': final thumbnail = media['thumbnail']; if (thumbnail != null) { - return thumbnail is String ? thumbnail : thumbnail.toString(); + return resolveImageUrlObject(thumbnail); } // Bluesky images @@ -372,11 +373,11 @@ class _NotificationItemState extends ConsumerState { if (firstImage != null) { final thumb = firstImage['thumb']; if (thumb != null) { - return thumb is String ? thumb : thumb.toString(); + return resolveImageUrlObject(thumb); } final fullsize = firstImage['fullsize']; if (fullsize != null) { - return fullsize is String ? fullsize : fullsize.toString(); + return resolveImageUrlObject(fullsize, isFullsize: true); } } } @@ -393,14 +394,14 @@ class _NotificationItemState extends ConsumerState { if (firstImage != null) { final thumb = firstImage['thumb']; if (thumb != null) { - return thumb is String ? thumb : thumb.toString(); + return resolveImageUrlObject(thumb); } } } } else if (nestedType == 'app.bsky.embed.video#view') { final thumbnail = nestedMedia['thumbnail']; if (thumbnail != null) { - return thumbnail is String ? thumbnail : thumbnail.toString(); + return resolveImageUrlObject(thumbnail); } } } @@ -437,7 +438,7 @@ class _NotificationItemState extends ConsumerState { if (authors.length == 1) { // Single avatar final author = authors[0].author; - final avatarUrl = author.avatar?.toString() ?? ''; + final avatarUrl = resolveImageUrlObject(author.avatar) ?? ''; final username = author.displayName ?? author.handle; final handleHash = author.handle.hashCode; @@ -467,7 +468,7 @@ class _NotificationItemState extends ConsumerState { ...authors.asMap().entries.map((entry) { final index = entry.key; final author = entry.value.author; - final avatarUrl = author.avatar?.toString() ?? ''; + final avatarUrl = resolveImageUrlObject(author.avatar) ?? ''; final username = author.displayName ?? author.handle; final handleHash = author.handle.hashCode; diff --git a/lib/src/features/posting/ui/widgets/mention_input_field.dart b/lib/src/features/posting/ui/widgets/mention_input_field.dart index 14bb238f..2d9da64d 100644 --- a/lib/src/features/posting/ui/widgets/mention_input_field.dart +++ b/lib/src/features/posting/ui/widgets/mention_input_field.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/design_system/components/molecules/input_field.dart'; import 'package:spark/src/core/design_system/tokens/constants.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/core/utils/text_formatter.dart'; import 'package:spark/src/features/posting/models/mention.dart'; import 'package:spark/src/features/posting/models/mention_controller.dart'; @@ -360,14 +361,15 @@ class _MentionInputFieldState extends ConsumerState { itemCount: typeaheadState.results.length, itemBuilder: (context, index) { final actor = typeaheadState.results[index]; + final avatarUrl = resolveImageUrlObject(actor.avatar); return ListTile( dense: true, leading: CircleAvatar( radius: 16, - backgroundImage: actor.avatar != null - ? NetworkImage(actor.avatar.toString()) + backgroundImage: avatarUrl != null + ? NetworkImage(avatarUrl) : null, - child: actor.avatar == null + child: avatarUrl == null ? const Icon(Icons.person, size: 16) : null, ), diff --git a/lib/src/features/search/ui/pages/search_page.dart b/lib/src/features/search/ui/pages/search_page.dart index 1c0c931e..999c9011 100644 --- a/lib/src/features/search/ui/pages/search_page.dart +++ b/lib/src/features/search/ui/pages/search_page.dart @@ -9,6 +9,7 @@ import 'package:spark/src/core/design_system/templates/explore_page_template.dar import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/features/search/providers/actor_typeahead_provider.dart'; import 'package:spark/src/features/search/providers/actor_typeahead_state.dart'; import 'package:spark/src/features/search/providers/post_search_provider.dart'; @@ -222,16 +223,15 @@ class _ActorTypeaheadSuggestions extends StatelessWidget { separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { final actor = state.results[index]; + final avatarUrl = resolveImageUrlObject(actor.avatar); return ListTile( onTap: () => onSuggestionSelected(actor), contentPadding: const EdgeInsets.symmetric(vertical: 4), leading: CircleAvatar( radius: 18, - backgroundImage: actor.avatar != null - ? NetworkImage(actor.avatar.toString()) - : null, - child: actor.avatar == null ? const Icon(Icons.person) : null, + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, + child: avatarUrl == null ? const Icon(Icons.person) : null, ), title: Text(actor.displayName ?? actor.handle), subtitle: Text('@${actor.handle}'), diff --git a/lib/src/features/settings/ui/pages/labeler_label_settings_page.dart b/lib/src/features/settings/ui/pages/labeler_label_settings_page.dart index 8c3478b8..40aa846f 100644 --- a/lib/src/features/settings/ui/pages/labeler_label_settings_page.dart +++ b/lib/src/features/settings/ui/pages/labeler_label_settings_page.dart @@ -12,6 +12,7 @@ import 'package:spark/src/core/network/atproto/data/models/labeler_models.dart'; import 'package:spark/src/core/network/atproto/data/repositories/actor_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/core/utils/logging/logging.dart'; import 'package:spark/src/features/settings/providers/settings_provider.dart'; import 'package:spark/src/features/settings/ui/widgets/widgets.dart'; @@ -570,7 +571,7 @@ class _LabelerLabelSettingsPageState borderRadius: BorderRadius.circular(100), child: profile.avatar != null ? CachedNetworkImage( - imageUrl: profile.avatar!.toString(), + imageUrl: resolveImageUrlOrEmpty(profile.avatar), width: 36, height: 36, fit: BoxFit.cover, diff --git a/lib/src/features/settings/ui/pages/labeler_management_page.dart b/lib/src/features/settings/ui/pages/labeler_management_page.dart index cb65655e..3cb07110 100644 --- a/lib/src/features/settings/ui/pages/labeler_management_page.dart +++ b/lib/src/features/settings/ui/pages/labeler_management_page.dart @@ -10,6 +10,7 @@ import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; import 'package:spark/src/core/network/atproto/data/repositories/actor_repository.dart'; import 'package:spark/src/core/network/atproto/data/repositories/sprk_repository.dart'; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/core/utils/logging/logging.dart'; import 'package:spark/src/features/settings/providers/settings_provider.dart'; @@ -398,7 +399,7 @@ class _LabelerManagementPageState extends ConsumerState borderRadius: BorderRadius.circular(100), child: profile.avatar != null ? CachedNetworkImage( - imageUrl: profile.avatar!.toString(), + imageUrl: resolveImageUrlOrEmpty(profile.avatar), width: 36, height: 36, fit: BoxFit.cover, diff --git a/lib/src/features/stories/ui/pages/author_stories_page.dart b/lib/src/features/stories/ui/pages/author_stories_page.dart index d8196bb3..802a6546 100644 --- a/lib/src/features/stories/ui/pages/author_stories_page.dart +++ b/lib/src/features/stories/ui/pages/author_stories_page.dart @@ -8,6 +8,7 @@ import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/data/models/actor_models.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/features/stories/ui/pages/story_page.dart'; @RoutePage() @@ -253,6 +254,8 @@ class _AuthorStoriesPageState extends ConsumerState return Scaffold(body: Center(child: Text(l10n.emptyNoStories))); } + final avatarUrl = resolveImageUrlObject(widget.author.avatar); + return Scaffold( backgroundColor: Colors.black, body: SafeArea( @@ -361,15 +364,20 @@ class _AuthorStoriesPageState extends ConsumerState shape: BoxShape.circle, ), child: ClipOval( - child: CachedNetworkImage( - imageUrl: widget.author.avatar.toString(), - fit: BoxFit.cover, - errorWidget: (context, url, error) => - const Icon( + child: avatarUrl != null + ? CachedNetworkImage( + imageUrl: avatarUrl, + fit: BoxFit.cover, + errorWidget: (context, url, error) => + const Icon( + Icons.person, + color: Colors.white, + ), + ) + : const Icon( Icons.person, color: Colors.white, ), - ), ), ), ), diff --git a/lib/src/features/stories/ui/pages/story_manager_page.dart b/lib/src/features/stories/ui/pages/story_manager_page.dart index a20607c6..6025664c 100644 --- a/lib/src/features/stories/ui/pages/story_manager_page.dart +++ b/lib/src/features/stories/ui/pages/story_manager_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:spark/src/features/stories/providers/story_auto_delete_provider.dart'; import 'package:spark/src/features/stories/providers/story_manager_provider.dart'; @@ -99,9 +100,13 @@ class StoryManagerPage extends ConsumerWidget { ? '${age.inMinutes}m' : 'now'; final thumbUrl = switch (story.media) { - MediaViewVideo(:final thumbnail) => thumbnail.toString(), - MediaViewImage(:final image) => image.thumb.toString(), - _ => story.author.avatar.toString(), + MediaViewVideo(:final thumbnail) => resolveImageUrlOrEmpty( + thumbnail, + ), + MediaViewImage(:final image) => resolveImageUrlOrEmpty( + image.thumb, + ), + _ => resolveImageUrlOrEmpty(story.author.avatar), }; return Material( color: Colors.transparent, diff --git a/lib/src/features/stories/ui/pages/story_page.dart b/lib/src/features/stories/ui/pages/story_page.dart index ef62e146..7cb39139 100644 --- a/lib/src/features/stories/ui/pages/story_page.dart +++ b/lib/src/features/stories/ui/pages/story_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; import 'package:spark/src/core/network/atproto/data/models/story_embed_models.dart'; import 'package:spark/src/core/routing/app_router.dart'; +import 'package:spark/src/core/utils/image_url_resolver.dart'; import 'package:video_player/video_player.dart'; @RoutePage() @@ -144,8 +145,11 @@ class _StoryPageState extends ConsumerState String _getImageUrl(StoryView story) { return switch (story.media) { - MediaViewImage(:final image) => image.fullsize.toString(), - _ => widget.story.author.avatar.toString(), + MediaViewImage(:final image) => resolveImageUrlOrEmpty( + image.fullsize, + isFullsize: true, + ), + _ => resolveImageUrlOrEmpty(widget.story.author.avatar), }; }