From everything-claude-code-mobile
Provides image loading patterns for Android Compose using Coil: AsyncImage, SubcomposeAsyncImage, caching strategies, transformations, placeholders, error handling. Useful for mobile apps.
npx claudepluginhub ahmed3elshaer/everything-claude-code-mobile --plugin everything-claude-code-mobileThis skill uses the workspace's default tool permissions.
```kotlin
Implement, review, or improve photo picking, camera capture, and media handling in iOS Swift apps using PhotoKit, AVFoundation, and PhotosPicker.
Provides Jetpack Compose patterns for state hoisting, remember variants, slot APIs, modifiers, side effects, theming, animations, and performance in Android UI development.
Provides Jetpack Compose patterns for Android apps: @Preview setup (LocalInspectionMode, Vico charts), Scaffold + bottom nav + insets, ModalBottomSheet scroll jitter, ripple clipping, SwipeToDismissBox transparency, locale-aware dates (MIUI quirks).
Share bugs, ideas, or general feedback.
dependencies {
implementation("io.coil-kt.coil3:coil-compose:3.0.4")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4")
}
AsyncImage(
model = article.imageUrl,
contentDescription = "Article cover image",
contentScale = ContentScale.Crop,
placeholder = painterResource(R.drawable.placeholder),
error = painterResource(R.drawable.error_image),
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(12.dp))
)
SubcomposeAsyncImage(
model = user.avatarUrl,
contentDescription = "User avatar",
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
) {
when (painter.state) {
is AsyncImagePainter.State.Loading -> {
ShimmerBox(modifier = Modifier.fillMaxSize())
}
is AsyncImagePainter.State.Error -> {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(16.dp)
)
}
else -> {
SubcomposeAsyncImageContent(contentScale = ContentScale.Crop)
}
}
}
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.crossfade(300)
.size(Size.ORIGINAL)
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = "Photo",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth()
)
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(user.avatarUrl)
.crossfade(true)
.transformations(
CircleCropTransformation(),
// or RoundedCornersTransformation(16f)
// or BlurTransformation(LocalContext.current, radius = 25f)
)
.build(),
contentDescription = "Avatar",
modifier = Modifier.size(48.dp)
)
// In Application class or Koin module
val imageLoader = ImageLoader.Builder(context)
.memoryCachePolicy(CachePolicy.ENABLED)
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.25) // 25% of app memory
.build()
}
.diskCachePolicy(CachePolicy.ENABLED)
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("image_cache"))
.maxSizeBytes(100L * 1024 * 1024) // 100 MB
.build()
}
.respectCacheHeaders(true)
.build()
val imageModule = module {
single {
ImageLoader.Builder(androidContext())
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(androidContext(), 0.25)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(androidContext().cacheDir.resolve("image_cache"))
.maxSizeBytes(100L * 1024 * 1024)
.build()
}
.crossfade(true)
.build()
}
}
// In Application.onCreate or Compose root
setSingletonImageLoaderFactory { context ->
get<ImageLoader>() // from Koin
}
// Preload images for better UX (e.g., in list adapter bind)
fun preloadImage(context: Context, url: String) {
val request = ImageRequest.Builder(context)
.data(url)
.size(200, 200)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
context.imageLoader.enqueue(request)
}
AsyncImage(url: URL(string: article.imageUrl)) { phase in
switch phase {
case .empty:
ProgressView()
.frame(maxWidth: .infinity, minHeight: 200)
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 12))
case .failure:
Image(systemName: "photo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 200)
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
@Observable
class ImageCache {
static let shared = ImageCache()
private let cache = NSCache<NSString, UIImage>()
private let session = URLSession.shared
init() {
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
}
func image(for url: URL) async throws -> UIImage {
let key = url.absoluteString as NSString
if let cached = cache.object(forKey: key) {
return cached
}
let (data, _) = try await session.data(from: url)
guard let image = UIImage(data: data) else {
throw ImageError.decodingFailed
}
cache.setObject(image, forKey: key, cost: data.count)
return image
}
func clearCache() {
cache.removeAllObjects()
}
}
struct CachedAsyncImage: View {
let url: URL?
@State private var image: UIImage?
@State private var isLoading = true
var body: some View {
Group {
if let image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
} else if isLoading {
ShimmerView()
} else {
Image(systemName: "photo")
.foregroundStyle(.secondary)
}
}
.task {
guard let url else {
isLoading = false
return
}
do {
image = try await ImageCache.shared.image(for: url)
} catch {
// log error
}
isLoading = false
}
}
}
@Composable
fun ShimmerBox(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "shimmer")
val alpha by transition.animateFloat(
initialValue = 0.3f,
targetValue = 0.9f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1000),
repeatMode = RepeatMode.Reverse
),
label = "shimmer_alpha"
)
Box(
modifier = modifier
.background(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha),
shape = RoundedCornerShape(8.dp)
)
)
}
// Usage
ShimmerBox(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(12.dp))
)
@Composable
fun ImageErrorState(
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.BrokenImage,
contentDescription = "Failed to load image",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
if (onRetry != null) {
TextButton(onClick = onRetry) {
Text("Retry")
}
}
}
}
}
// Coil automatically downsamples, but for manual control:
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(highResUrl)
.size(400, 300) // downsample to target size
.precision(Precision.INEXACT) // allow slight size differences
.build(),
contentDescription = "Thumbnail",
modifier = Modifier.size(200.dp, 150.dp)
)
// iOS: Downsample large images
func downsample(imageAt url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, imageSourceOptions) else {
return nil
}
let maxDimension = max(pointSize.width, pointSize.height) * scale
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension
]
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else {
return nil
}
return UIImage(cgImage: cgImage)
}
// Circle crop avatar
@Composable
fun Avatar(url: String?, size: Dp = 48.dp) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(true)
.transformations(CircleCropTransformation())
.build(),
contentDescription = "User avatar",
placeholder = painterResource(R.drawable.avatar_placeholder),
error = painterResource(R.drawable.avatar_default),
modifier = Modifier
.size(size)
.clip(CircleShape)
.border(2.dp, MaterialTheme.colorScheme.outline, CircleShape)
)
}
// Rounded corners card image
@Composable
fun CardImage(url: String?, modifier: Modifier = Modifier) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = painterResource(R.drawable.placeholder),
modifier = modifier.clip(RoundedCornerShape(12.dp))
)
}
crossfade(true) for smoother transitions from placeholder to loaded image.ContentScale.Crop for fixed-size containers, ContentScale.Fit for flexible ones.onTrimMemory / didReceiveMemoryWarning).