mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Add CDN image variants
This commit is contained in:
@@ -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<String, String>.from(uri.queryParameters);
|
||||
params['class'] = variant.name;
|
||||
return uri.replace(queryParameters: params).toString();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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=<variant>` 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`.
|
||||
@@ -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), '');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user