diff --git a/lib/utils/image_url.dart b/lib/utils/image_url.dart new file mode 100644 index 0000000..f23812c --- /dev/null +++ b/lib/utils/image_url.dart @@ -0,0 +1,23 @@ +const _cdnHost = 'cdn.zapstore.dev'; + +/// CDN image variants supported by `cdn.zapstore.dev`. +enum CdnImageVariant { + icon, + iconsm, + thumbsm, + thumblg, +} + +/// Add a CDN image variant only for Zapstore's CDN. +/// +/// Non-CDN hosts, null/empty values, and unparseable URLs are returned unchanged. +String? getCdnImageUrl(String? imageUrl, CdnImageVariant variant) { + if (imageUrl == null || imageUrl.isEmpty) return imageUrl; + + final uri = Uri.tryParse(imageUrl); + if (uri == null || uri.host != _cdnHost) return imageUrl; + + final params = Map.from(uri.queryParameters); + params['class'] = variant.name; + return uri.replace(queryParameters: params).toString(); +} diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index 2462ef2..c7b93d1 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -7,6 +7,7 @@ import 'package:markdown/markdown.dart' as md; import 'package:skeletonizer/skeletonizer.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/nostr_route.dart'; +import 'package:zapstore/utils/image_url.dart'; import 'package:zapstore/utils/url_utils.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/widgets/zap_widgets.dart'; @@ -180,7 +181,10 @@ class AppCard extends HookConsumerWidget { } Widget _buildAppIcon(BuildContext context, double size) { - final iconUrl = firstValidHttpUrl(app!.icons); + final iconUrl = getCdnImageUrl( + firstValidHttpUrl(app!.icons), + CdnImageVariant.icon, + ); return SizedBox( width: size, diff --git a/lib/widgets/app_header.dart b/lib/widgets/app_header.dart index 364aaa2..46d8b8e 100644 --- a/lib/widgets/app_header.dart +++ b/lib/widgets/app_header.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:models/models.dart'; import 'package:zapstore/widgets/version_pill_widget.dart'; +import 'package:zapstore/utils/image_url.dart'; import 'package:zapstore/utils/url_utils.dart'; class AppHeader extends StatelessWidget { @@ -13,7 +14,10 @@ class AppHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final iconUrl = firstValidHttpUrl(app.icons); + final iconUrl = getCdnImageUrl( + firstValidHttpUrl(app.icons), + CdnImageVariant.icon, + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart index 973cf13..45d311f 100644 --- a/lib/widgets/app_stack_container.dart +++ b/lib/widgets/app_stack_container.dart @@ -7,6 +7,7 @@ import 'package:zapstore/utils/nostr_route.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../utils/extensions.dart'; +import '../utils/image_url.dart'; import '../utils/url_utils.dart'; import '../theme.dart'; import '../services/package_manager/package_manager.dart'; @@ -500,7 +501,10 @@ class _AppIconTile extends StatelessWidget { @override Widget build(BuildContext context) { - final iconUrl = firstValidHttpUrl(app.icons); + final iconUrl = getCdnImageUrl( + firstValidHttpUrl(app.icons), + CdnImageVariant.iconsm, + ); return Padding( padding: const EdgeInsets.all(2), diff --git a/lib/widgets/common/profile_avatar.dart b/lib/widgets/common/profile_avatar.dart index e47b4cd..4748134 100644 --- a/lib/widgets/common/profile_avatar.dart +++ b/lib/widgets/common/profile_avatar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:models/models.dart'; +import 'package:zapstore/utils/image_url.dart'; import 'package:zapstore/utils/url_utils.dart'; import '../../theme.dart'; @@ -42,7 +43,10 @@ class ProfileAvatar extends StatelessWidget { @override Widget build(BuildContext context) { - final pictureUrl = sanitizeHttpUrl(profile?.pictureUrl); + final pictureUrl = getCdnImageUrl( + sanitizeHttpUrl(profile?.pictureUrl), + radius < 24 ? CdnImageVariant.iconsm : CdnImageVariant.icon, + ); Widget avatar = ClipOval( child: pictureUrl != null diff --git a/lib/widgets/rounded_image.dart b/lib/widgets/rounded_image.dart index 981f974..7a457b8 100644 --- a/lib/widgets/rounded_image.dart +++ b/lib/widgets/rounded_image.dart @@ -1,5 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:zapstore/utils/image_url.dart'; import 'package:zapstore/utils/url_utils.dart'; /// A rounded image widget with fallback to person icon @@ -10,15 +11,17 @@ class RoundedImage extends StatelessWidget { this.url, this.size = 22, this.radius = 60, + this.cdnVariant = CdnImageVariant.iconsm, }); final String? url; final double size; final double radius; + final CdnImageVariant cdnVariant; @override Widget build(BuildContext context) { - final sanitizedUrl = sanitizeHttpUrl(url); + final sanitizedUrl = getCdnImageUrl(sanitizeHttpUrl(url), cdnVariant); final fallbackContainer = Container( height: size, width: size, diff --git a/lib/widgets/screenshots_gallery.dart b/lib/widgets/screenshots_gallery.dart index 147a7dc..c642806 100644 --- a/lib/widgets/screenshots_gallery.dart +++ b/lib/widgets/screenshots_gallery.dart @@ -3,6 +3,7 @@ import 'package:easy_image_viewer/easy_image_viewer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:models/models.dart'; +import 'package:zapstore/utils/image_url.dart'; import 'package:zapstore/utils/url_utils.dart'; // Local shimmer duplicated to avoid private import @@ -16,6 +17,11 @@ class ScreenshotsGallery extends HookWidget { final imageUrls = filterValidHttpUrls(app.images); if (imageUrls.isEmpty) return const SizedBox.shrink(); + final thumbUrls = [ + for (final url in imageUrls) + getCdnImageUrl(url, CdnImageVariant.thumbsm)!, + ]; + final scrollController = useScrollController(); useListenable(scrollController); @@ -25,9 +31,9 @@ class ScreenshotsGallery extends HookWidget { controller: scrollController, padding: EdgeInsets.zero, scrollDirection: Axis.horizontal, - itemCount: imageUrls.length, + itemCount: thumbUrls.length, itemBuilder: (context, index) { - final imageUrl = imageUrls[index]; + final imageUrl = thumbUrls[index]; return GestureDetector( onTap: () => _showImageViewer(context, imageUrls, index), child: Container( @@ -78,7 +84,11 @@ class ScreenshotsGallery extends HookWidget { int initialIndex, ) { final imageProviders = imageUrls - .map((url) => CachedNetworkImageProvider(url) as ImageProvider) + .map( + (url) => CachedNetworkImageProvider( + getCdnImageUrl(url, CdnImageVariant.thumblg)!, + ) as ImageProvider, + ) .toList(); showImageViewerPager( diff --git a/lib/widgets/search_app_card.dart b/lib/widgets/search_app_card.dart index e9b9904..b36a030 100644 --- a/lib/widgets/search_app_card.dart +++ b/lib/widgets/search_app_card.dart @@ -6,6 +6,7 @@ import 'package:models/models.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/nostr_route.dart'; +import 'package:zapstore/utils/image_url.dart'; import 'package:zapstore/utils/url_utils.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import '../theme.dart'; @@ -27,7 +28,10 @@ class SearchAppCard extends ConsumerWidget { final isInstalled = ref.watch(installedPackageProvider(app!.identifier)) != null; - final iconUrl = firstValidHttpUrl(app!.icons); + final iconUrl = getCdnImageUrl( + firstValidHttpUrl(app!.icons), + CdnImageVariant.icon, + ); const iconSize = 48.0; return GestureDetector( diff --git a/spec/work/WORK-027-cdn-image-variants.md b/spec/work/WORK-027-cdn-image-variants.md new file mode 100644 index 0000000..ca18826 --- /dev/null +++ b/spec/work/WORK-027-cdn-image-variants.md @@ -0,0 +1,34 @@ +# WORK-027 — CDN Image Variants + +**Feature:** (utility parity with webapp `image-url.js`; no FEAT required) +**Status:** Complete + +## Tasks + +- [x] 1. Add `getCdnImageUrl` matching webapp logic + - Files: `lib/utils/image_url.dart`, `test/utils/image_url_test.dart` +- [x] 2. Apply variants at image load sites + - App icons → `icon` / stack tiles → `iconsm` + - Screenshot thumbs → `thumbsm`, lightbox → `thumblg` + - Profile/avatar widgets apply icon/iconsm when on CDN (no-op otherwise) +- [x] 3. Self-review against INVARIANTS.md + +## Test Coverage + +| Scenario | Expected | Status | +|----------|----------|--------| +| CDN URL + variant | `?class=` appended | [x] | +| Existing query params | Preserved, `class` set | [x] | +| Non-CDN / relative / null | Unchanged | [x] | + +## Decisions + +### 2026-07-16 — Port webapp CDN class param + +**Context:** Webapp requests sized variants via `?class=` on `cdn.zapstore.dev`. +**Decision:** Same helper and call-site mapping in the Flutter app. +**Rationale:** Smaller transfers for list icons and screenshot thumbs; no-op for other hosts. + +## Progress Notes + +Mirroring `webapp/src/lib/utils/image-url.js`. diff --git a/test/utils/image_url_test.dart b/test/utils/image_url_test.dart new file mode 100644 index 0000000..ad9db8c --- /dev/null +++ b/test/utils/image_url_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zapstore/utils/image_url.dart'; + +void main() { + group('getCdnImageUrl', () { + test('adds the requested class to Zapstore CDN URLs', () { + expect( + getCdnImageUrl('https://cdn.zapstore.dev/file.png', CdnImageVariant.icon), + 'https://cdn.zapstore.dev/file.png?class=icon', + ); + expect( + getCdnImageUrl( + 'https://cdn.zapstore.dev/file.png', + CdnImageVariant.iconsm, + ), + 'https://cdn.zapstore.dev/file.png?class=iconsm', + ); + }); + + test('preserves existing query parameters', () { + expect( + getCdnImageUrl( + 'https://cdn.zapstore.dev/file.png?x=1', + CdnImageVariant.thumbsm, + ), + 'https://cdn.zapstore.dev/file.png?x=1&class=thumbsm', + ); + }); + + test('does not transform other hosts or invalid URLs', () { + expect( + getCdnImageUrl('https://example.com/file.png', CdnImageVariant.icon), + 'https://example.com/file.png', + ); + expect( + getCdnImageUrl('/file.png', CdnImageVariant.icon), + '/file.png', + ); + }); + + test('returns null and empty unchanged', () { + expect(getCdnImageUrl(null, CdnImageVariant.icon), isNull); + expect(getCdnImageUrl('', CdnImageVariant.icon), ''); + }); + }); +}