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:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
import 'package:zapstore/utils/nostr_route.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/utils/url_utils.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/widgets/zap_widgets.dart';
|
import 'package:zapstore/widgets/zap_widgets.dart';
|
||||||
@@ -180,7 +181,10 @@ class AppCard extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppIcon(BuildContext context, double size) {
|
Widget _buildAppIcon(BuildContext context, double size) {
|
||||||
final iconUrl = firstValidHttpUrl(app!.icons);
|
final iconUrl = getCdnImageUrl(
|
||||||
|
firstValidHttpUrl(app!.icons),
|
||||||
|
CdnImageVariant.icon,
|
||||||
|
);
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: size,
|
width: size,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:zapstore/widgets/version_pill_widget.dart';
|
import 'package:zapstore/widgets/version_pill_widget.dart';
|
||||||
|
import 'package:zapstore/utils/image_url.dart';
|
||||||
import 'package:zapstore/utils/url_utils.dart';
|
import 'package:zapstore/utils/url_utils.dart';
|
||||||
|
|
||||||
class AppHeader extends StatelessWidget {
|
class AppHeader extends StatelessWidget {
|
||||||
@@ -13,7 +14,10 @@ class AppHeader extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final iconUrl = firstValidHttpUrl(app.icons);
|
final iconUrl = getCdnImageUrl(
|
||||||
|
firstValidHttpUrl(app.icons),
|
||||||
|
CdnImageVariant.icon,
|
||||||
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
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:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import '../utils/extensions.dart';
|
import '../utils/extensions.dart';
|
||||||
|
import '../utils/image_url.dart';
|
||||||
import '../utils/url_utils.dart';
|
import '../utils/url_utils.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
import '../services/package_manager/package_manager.dart';
|
import '../services/package_manager/package_manager.dart';
|
||||||
@@ -500,7 +501,10 @@ class _AppIconTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final iconUrl = firstValidHttpUrl(app.icons);
|
final iconUrl = getCdnImageUrl(
|
||||||
|
firstValidHttpUrl(app.icons),
|
||||||
|
CdnImageVariant.iconsm,
|
||||||
|
);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(2),
|
padding: const EdgeInsets.all(2),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
|
import 'package:zapstore/utils/image_url.dart';
|
||||||
import 'package:zapstore/utils/url_utils.dart';
|
import 'package:zapstore/utils/url_utils.dart';
|
||||||
import '../../theme.dart';
|
import '../../theme.dart';
|
||||||
|
|
||||||
@@ -42,7 +43,10 @@ class ProfileAvatar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pictureUrl = sanitizeHttpUrl(profile?.pictureUrl);
|
final pictureUrl = getCdnImageUrl(
|
||||||
|
sanitizeHttpUrl(profile?.pictureUrl),
|
||||||
|
radius < 24 ? CdnImageVariant.iconsm : CdnImageVariant.icon,
|
||||||
|
);
|
||||||
|
|
||||||
Widget avatar = ClipOval(
|
Widget avatar = ClipOval(
|
||||||
child: pictureUrl != null
|
child: pictureUrl != null
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:zapstore/utils/image_url.dart';
|
||||||
import 'package:zapstore/utils/url_utils.dart';
|
import 'package:zapstore/utils/url_utils.dart';
|
||||||
|
|
||||||
/// A rounded image widget with fallback to person icon
|
/// A rounded image widget with fallback to person icon
|
||||||
@@ -10,15 +11,17 @@ class RoundedImage extends StatelessWidget {
|
|||||||
this.url,
|
this.url,
|
||||||
this.size = 22,
|
this.size = 22,
|
||||||
this.radius = 60,
|
this.radius = 60,
|
||||||
|
this.cdnVariant = CdnImageVariant.iconsm,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String? url;
|
final String? url;
|
||||||
final double size;
|
final double size;
|
||||||
final double radius;
|
final double radius;
|
||||||
|
final CdnImageVariant cdnVariant;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final sanitizedUrl = sanitizeHttpUrl(url);
|
final sanitizedUrl = getCdnImageUrl(sanitizeHttpUrl(url), cdnVariant);
|
||||||
final fallbackContainer = Container(
|
final fallbackContainer = Container(
|
||||||
height: size,
|
height: size,
|
||||||
width: size,
|
width: size,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:easy_image_viewer/easy_image_viewer.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
|
import 'package:zapstore/utils/image_url.dart';
|
||||||
import 'package:zapstore/utils/url_utils.dart';
|
import 'package:zapstore/utils/url_utils.dart';
|
||||||
// Local shimmer duplicated to avoid private import
|
// Local shimmer duplicated to avoid private import
|
||||||
|
|
||||||
@@ -16,6 +17,11 @@ class ScreenshotsGallery extends HookWidget {
|
|||||||
final imageUrls = filterValidHttpUrls(app.images);
|
final imageUrls = filterValidHttpUrls(app.images);
|
||||||
if (imageUrls.isEmpty) return const SizedBox.shrink();
|
if (imageUrls.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final thumbUrls = [
|
||||||
|
for (final url in imageUrls)
|
||||||
|
getCdnImageUrl(url, CdnImageVariant.thumbsm)!,
|
||||||
|
];
|
||||||
|
|
||||||
final scrollController = useScrollController();
|
final scrollController = useScrollController();
|
||||||
useListenable(scrollController);
|
useListenable(scrollController);
|
||||||
|
|
||||||
@@ -25,9 +31,9 @@ class ScreenshotsGallery extends HookWidget {
|
|||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: imageUrls.length,
|
itemCount: thumbUrls.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final imageUrl = imageUrls[index];
|
final imageUrl = thumbUrls[index];
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _showImageViewer(context, imageUrls, index),
|
onTap: () => _showImageViewer(context, imageUrls, index),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -78,7 +84,11 @@ class ScreenshotsGallery extends HookWidget {
|
|||||||
int initialIndex,
|
int initialIndex,
|
||||||
) {
|
) {
|
||||||
final imageProviders = imageUrls
|
final imageProviders = imageUrls
|
||||||
.map((url) => CachedNetworkImageProvider(url) as ImageProvider)
|
.map(
|
||||||
|
(url) => CachedNetworkImageProvider(
|
||||||
|
getCdnImageUrl(url, CdnImageVariant.thumblg)!,
|
||||||
|
) as ImageProvider,
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
showImageViewerPager(
|
showImageViewerPager(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:models/models.dart';
|
|||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
import 'package:zapstore/utils/nostr_route.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/utils/url_utils.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
@@ -27,7 +28,10 @@ class SearchAppCard extends ConsumerWidget {
|
|||||||
final isInstalled =
|
final isInstalled =
|
||||||
ref.watch(installedPackageProvider(app!.identifier)) != null;
|
ref.watch(installedPackageProvider(app!.identifier)) != null;
|
||||||
|
|
||||||
final iconUrl = firstValidHttpUrl(app!.icons);
|
final iconUrl = getCdnImageUrl(
|
||||||
|
firstValidHttpUrl(app!.icons),
|
||||||
|
CdnImageVariant.icon,
|
||||||
|
);
|
||||||
const iconSize = 48.0;
|
const iconSize = 48.0;
|
||||||
|
|
||||||
return GestureDetector(
|
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