From da8559508e9ba54c8b131f32ea05e2d4041a2823 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Sun, 8 Feb 2026 12:24:33 +0100 Subject: [PATCH 1/5] feat: make image fallback customizable --- .../main/java/voltra/generated/ShortNames.kt | 2 + .../voltra/glance/renderers/RenderCommon.kt | 58 +++++++++ .../glance/renderers/TextAndImageRenderers.kt | 24 +++- .../parameters/AndroidImageParameters.kt | 2 + data/components.json | 22 ++++ ios/shared/ShortNames.swift | 2 + .../Parameters/ImageParameters.swift | 5 + ios/ui/Views/VoltraImage.swift | 111 ++++++++++-------- src/android/jsx/props/Image.ts | 6 + src/jsx/__tests__/Image.node.test.tsx | 12 ++ src/jsx/props/AndroidImage.ts | 6 + src/jsx/props/Image.ts | 6 + src/payload/short-names.ts | 4 + website/docs/android/components/visual.md | 2 + website/docs/ios/components/visual.md | 2 + 15 files changed, 211 insertions(+), 53 deletions(-) diff --git a/android/src/main/java/voltra/generated/ShortNames.kt b/android/src/main/java/voltra/generated/ShortNames.kt index 2503b989..ef848f02 100644 --- a/android/src/main/java/voltra/generated/ShortNames.kt +++ b/android/src/main/java/voltra/generated/ShortNames.kt @@ -48,6 +48,8 @@ object ShortNames { "en" to "enabled", "end" to "endAtMs", "ep" to "endPoint", + "fb" to "fallback", + "fbc" to "fallbackColor", "fmh" to "fillMaxHeight", "fmw" to "fillMaxWidth", "fsh" to "fixedSizeHorizontal", diff --git a/android/src/main/java/voltra/glance/renderers/RenderCommon.kt b/android/src/main/java/voltra/glance/renderers/RenderCommon.kt index 419e8409..2774f38f 100644 --- a/android/src/main/java/voltra/glance/renderers/RenderCommon.kt +++ b/android/src/main/java/voltra/glance/renderers/RenderCommon.kt @@ -126,6 +126,64 @@ fun extractImageProvider(sourceProp: Any?): ImageProvider? { return null } +/** + * Parse a prop value into a VoltraNode (used for component-typed props). + */ +@Suppress("UNCHECKED_CAST") +fun parseVoltraNode(value: Any?): VoltraNode? { + return when (value) { + null -> { + null + } + + is VoltraNode -> { + value + } + + is String -> { + VoltraNode.Text(value) + } + + is Number -> { + VoltraNode.Text(value.toString()) + } + + is Boolean -> { + VoltraNode.Text(value.toString()) + } + + is List<*> -> { + val elements = value.mapNotNull { parseVoltraNode(it) } + if (elements.isEmpty()) null else VoltraNode.Array(elements) + } + + is Map<*, *> -> { + val map = value as Map + val ref = map["\$r"] as? Number + if (ref != null) { + return VoltraNode.Ref(ref.toInt()) + } + + val typeId = map["t"] as? Number ?: return null + val id = map["i"] as? String + val child = parseVoltraNode(map["c"]) + val props = map["p"] as? Map + VoltraNode.Element( + VoltraElement( + t = typeId.toInt(), + i = id, + c = child, + p = props, + ), + ) + } + + else -> { + null + } + } +} + /** * Main dispatcher for rendering any VoltraNode. * Handles Element, Array, Ref, Text, and null cases. diff --git a/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt b/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt index 83060e71..4fe5724b 100644 --- a/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt +++ b/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt @@ -3,15 +3,16 @@ package voltra.glance.renderers import androidx.compose.runtime.Composable import androidx.glance.GlanceModifier import androidx.glance.Image -import androidx.glance.LocalContext +import androidx.glance.background import androidx.glance.text.Text +import androidx.glance.unit.ColorProvider import com.google.gson.Gson import voltra.glance.LocalVoltraRenderContext -import voltra.glance.ResolvedStyle import voltra.glance.applyClickableIfNeeded import voltra.glance.resolveAndApplyStyle import voltra.models.VoltraElement import voltra.models.VoltraNode +import voltra.styling.JSColorParser import voltra.styling.toGlanceTextStyle private const val TAG = "TextAndImageRenderers" @@ -94,7 +95,24 @@ fun RenderImage( alpha = alpha, ) } else { - androidx.glance.layout.Box(modifier = finalModifier) {} + val fallbackProp = element.p?.get("fallback") ?: element.p?.get("fb") + val fallbackNode = parseVoltraNode(fallbackProp) + if (fallbackNode != null) { + androidx.glance.layout.Box(modifier = finalModifier) { + RenderNode(fallbackNode) + } + } else { + val fallbackColorProp = (element.p?.get("fallbackColor") ?: element.p?.get("fbc")) as? String + val fallbackColor = + JSColorParser.parse(fallbackColorProp) ?: JSColorParser.parse("#E0E0E0") + val backgroundModifier = + if (fallbackColor != null) { + finalModifier.background(ColorProvider(fallbackColor)) + } else { + finalModifier + } + androidx.glance.layout.Box(modifier = backgroundModifier) {} + } } } diff --git a/android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt index 9c808ec4..ecb9862f 100644 --- a/android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt +++ b/android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt @@ -19,4 +19,6 @@ data class AndroidImageParameters( val source: String, /** Resizing mode */ val resizeMode: String? = null, + /** Background color used when the image is missing */ + val fallbackColor: String? = null, ) diff --git a/data/components.json b/data/components.json index 9e8e858c..b858ea68 100644 --- a/data/components.json +++ b/data/components.json @@ -91,6 +91,8 @@ "color": "c", "letterSpacing": "ls", "fontVariant": "fvar", + "fallback": "fb", + "fallbackColor": "fbc", "width": "w", "opacity": "op", "overflow": "ov", @@ -310,6 +312,16 @@ "enum": ["cover", "contain", "stretch", "repeat", "center"], "default": "cover", "description": "How the image should be resized to fit its container" + }, + "fallbackColor": { + "type": "string", + "optional": true, + "description": "Background color used when the image is missing" + }, + "fallback": { + "type": "component", + "optional": true, + "description": "Custom fallback content rendered when the image is missing" } } }, @@ -331,6 +343,16 @@ "enum": ["cover", "contain", "stretch", "repeat", "center"], "default": "cover", "description": "Resizing mode" + }, + "fallbackColor": { + "type": "string", + "optional": true, + "description": "Background color used when the image is missing" + }, + "fallback": { + "type": "component", + "optional": true, + "description": "Custom fallback content rendered when the image is missing" } } }, diff --git a/ios/shared/ShortNames.swift b/ios/shared/ShortNames.swift index 74e55675..0fe0d3f1 100644 --- a/ios/shared/ShortNames.swift +++ b/ios/shared/ShortNames.swift @@ -45,6 +45,8 @@ public enum ShortNames { "en": "enabled", "end": "endAtMs", "ep": "endPoint", + "fb": "fallback", + "fbc": "fallbackColor", "fmh": "fillMaxHeight", "fmw": "fillMaxWidth", "fsh": "fixedSizeHorizontal", diff --git a/ios/ui/Generated/Parameters/ImageParameters.swift b/ios/ui/Generated/Parameters/ImageParameters.swift index 17fcd390..6b97c9f2 100644 --- a/ios/ui/Generated/Parameters/ImageParameters.swift +++ b/ios/ui/Generated/Parameters/ImageParameters.swift @@ -16,14 +16,19 @@ public struct ImageParameters: ComponentParameters { /// How the image should be resized to fit its container public let resizeMode: String + /// Background color used when the image is missing + public let fallbackColor: String? + enum CodingKeys: String, CodingKey { case source case resizeMode + case fallbackColor } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) source = try container.decodeIfPresent(String.self, forKey: .source) resizeMode = try container.decodeIfPresent(String.self, forKey: .resizeMode) ?? "cover" + fallbackColor = try container.decodeIfPresent(String.self, forKey: .fallbackColor) } } diff --git a/ios/ui/Views/VoltraImage.swift b/ios/ui/Views/VoltraImage.swift index 87a1e92f..e82a8ce7 100644 --- a/ios/ui/Views/VoltraImage.swift +++ b/ios/ui/Views/VoltraImage.swift @@ -11,16 +11,13 @@ public struct VoltraImage: VoltraView { self.element = element } - /// Creates an Image from the source parameter, falling back to a system photo icon if invalid or not found - private func createImage(from source: String?) -> Image { - // Fallback image when source is invalid or not found - let fallbackImage = Image(systemName: "photo") - + /// Creates an Image from the source parameter, returning nil if invalid or not found + private func createImage(from source: String?) -> Image? { guard let sourceString = source, let sourceData = sourceString.data(using: .utf8), let sourceDict = try? JSONSerialization.jsonObject(with: sourceData) as? [String: Any] else { - return fallbackImage + return nil } // Check for base64 first @@ -48,55 +45,69 @@ public struct VoltraImage: VoltraView { } } - // Asset not found or invalid, use fallback - return fallbackImage + // Asset not found or invalid + return nil } @ViewBuilder public var body: some View { let resizeMode = params.resizeMode.lowercased() - let baseImage = createImage(from: params.source) - - switch resizeMode { - case "cover": - // Fill container, may crop - baseImage - .resizable() - .scaledToFill() - .clipped() - .applyStyle(element.style) - - case "contain": - // Fit within container, may leave space - baseImage - .resizable() - .scaledToFit() - .applyStyle(element.style) - - case "stretch": - // Stretch to fill, may distort - baseImage - .resizable() - .applyStyle(element.style) - - case "repeat": - // Tile the image - baseImage - .resizable(resizingMode: .tile) - .applyStyle(element.style) - - case "center": - // Center without scaling - baseImage - .applyStyle(element.style) - - default: - // Default to cover - baseImage - .resizable() - .scaledToFill() - .clipped() - .applyStyle(element.style) + if let baseImage = createImage(from: params.source) { + switch resizeMode { + case "cover": + // Fill container, may crop + baseImage + .resizable() + .scaledToFill() + .clipped() + .applyStyle(element.style) + + case "contain": + // Fit within container, may leave space + baseImage + .resizable() + .scaledToFit() + .applyStyle(element.style) + + case "stretch": + // Stretch to fill, may distort + baseImage + .resizable() + .applyStyle(element.style) + + case "repeat": + // Tile the image + baseImage + .resizable(resizingMode: .tile) + .applyStyle(element.style) + + case "center": + // Center without scaling + baseImage + .applyStyle(element.style) + + default: + // Default to cover + baseImage + .resizable() + .scaledToFill() + .clipped() + .applyStyle(element.style) + } + } else { + let fallbackNode = element.componentProp("fallback") + if !fallbackNode.isEmpty { + fallbackNode + .applyStyle(element.style) + } else { + let fallbackColor = + JSColorParser.parse(params.fallbackColor) ?? + JSColorParser.parse("#E0E0E0") ?? + Color.gray + Rectangle() + .fill(fallbackColor) + .applyStyle(element.style) + } } } } diff --git a/src/android/jsx/props/Image.ts b/src/android/jsx/props/Image.ts index 8fd09f0d..cc088b54 100644 --- a/src/android/jsx/props/Image.ts +++ b/src/android/jsx/props/Image.ts @@ -2,6 +2,8 @@ // DO NOT EDIT MANUALLY - Changes will be overwritten // Schema version: 1.0.0 +import type { ReactNode } from 'react' + import type { VoltraAndroidBaseProps } from '../baseProps.js' export type ImageProps = VoltraAndroidBaseProps & { @@ -17,4 +19,8 @@ export type ImageProps = VoltraAndroidBaseProps & { alpha?: number /** Tint color */ tintColor?: string + /** Background color used when the image is missing */ + fallbackColor?: string + /** Custom fallback content rendered when the image is missing */ + fallback?: ReactNode } diff --git a/src/jsx/__tests__/Image.node.test.tsx b/src/jsx/__tests__/Image.node.test.tsx index ee48f354..c999b1a4 100644 --- a/src/jsx/__tests__/Image.node.test.tsx +++ b/src/jsx/__tests__/Image.node.test.tsx @@ -2,6 +2,7 @@ import React from 'react' import { renderVoltraVariantToJson } from '../../renderer/renderer' import { Image } from '../Image' +import { Text } from '../Text' describe('Image Component', () => { test('Asset name source', () => { @@ -28,6 +29,17 @@ describe('Image Component', () => { expect(output.p?.src).toBeUndefined() }) + test('fallbackColor', () => { + const output = renderVoltraVariantToJson() + expect(output.p.fbc).toBe('#E0E0E0') + }) + + test('fallback node', () => { + const output = renderVoltraVariantToJson(Missing} />) + expect(output.p.fb).toBeDefined() + expect((output.p.fb as any).t).toBeDefined() + }) + test('resizeMode cover', () => { const output = renderVoltraVariantToJson() expect(output.p.rm).toBe('cover') diff --git a/src/jsx/props/AndroidImage.ts b/src/jsx/props/AndroidImage.ts index 1432a3a5..7d524516 100644 --- a/src/jsx/props/AndroidImage.ts +++ b/src/jsx/props/AndroidImage.ts @@ -2,6 +2,8 @@ // DO NOT EDIT MANUALLY - Changes will be overwritten // Schema version: 1.0.0 +import type { ReactNode } from 'react' + import type { VoltraBaseProps } from '../baseProps' export type AndroidImageProps = VoltraBaseProps & { @@ -9,4 +11,8 @@ export type AndroidImageProps = VoltraBaseProps & { source: Record /** Resizing mode */ resizeMode?: 'cover' | 'contain' | 'stretch' | 'repeat' | 'center' + /** Background color used when the image is missing */ + fallbackColor?: string + /** Custom fallback content rendered when the image is missing */ + fallback?: ReactNode } diff --git a/src/jsx/props/Image.ts b/src/jsx/props/Image.ts index af0f58df..15852ffa 100644 --- a/src/jsx/props/Image.ts +++ b/src/jsx/props/Image.ts @@ -2,6 +2,8 @@ // DO NOT EDIT MANUALLY - Changes will be overwritten // Schema version: 1.0.0 +import type { ReactNode } from 'react' + import type { VoltraBaseProps } from '../baseProps' export type ImageProps = VoltraBaseProps & { @@ -9,4 +11,8 @@ export type ImageProps = VoltraBaseProps & { source?: Record /** How the image should be resized to fit its container */ resizeMode?: 'cover' | 'contain' | 'stretch' | 'repeat' | 'center' + /** Background color used when the image is missing */ + fallbackColor?: string + /** Custom fallback content rendered when the image is missing */ + fallback?: ReactNode } diff --git a/src/payload/short-names.ts b/src/payload/short-names.ts index c47494fe..b8a2a27f 100644 --- a/src/payload/short-names.ts +++ b/src/payload/short-names.ts @@ -41,6 +41,8 @@ export const NAME_TO_SHORT: Record = { enabled: 'en', endAtMs: 'end', endPoint: 'ep', + fallback: 'fb', + fallbackColor: 'fbc', fillMaxHeight: 'fmh', fillMaxWidth: 'fmw', fixedSizeHorizontal: 'fsh', @@ -187,6 +189,8 @@ export const SHORT_TO_NAME: Record = { en: 'enabled', end: 'endAtMs', ep: 'endPoint', + fb: 'fallback', + fbc: 'fallbackColor', fmh: 'fillMaxHeight', fmw: 'fillMaxWidth', fsh: 'fixedSizeHorizontal', diff --git a/website/docs/android/components/visual.md b/website/docs/android/components/visual.md index 2bc766f7..1fa71272 100644 --- a/website/docs/android/components/visual.md +++ b/website/docs/android/components/visual.md @@ -26,6 +26,8 @@ Displays bitmap images from the asset catalog or base64 encoded data. - `contentDescription` (string, optional): Accessibility description for the image. - `alpha` (number, optional): Opacity value from 0.0 to 1.0. - `tintColor` (string, optional): Color to tint the image with. +- `fallbackColor` (string, optional): Background color used when the image is missing (defaults to `#E0E0E0`). +- `fallback` (ReactNode, optional): Custom content rendered when the image is missing. :::tip Image Preloading For dynamic images from remote URLs, use the [Image Preloading](../development/image-preloading) API to cache them locally for use in widgets. diff --git a/website/docs/ios/components/visual.md b/website/docs/ios/components/visual.md index b1b9bcc4..397b515a 100644 --- a/website/docs/ios/components/visual.md +++ b/website/docs/ios/components/visual.md @@ -31,6 +31,8 @@ Displays bitmap images from the asset catalog or base64 encoded data. - `source` (object, optional): Image source object (`assetName` or `base64`) - `resizeMode` (string, optional): `"cover"`, `"contain"`, `"stretch"`, `"repeat"`, or `"center"` +- `fallbackColor` (string, optional): Background color used when the image is missing (defaults to `#E0E0E0`) +- `fallback` (ReactNode, optional): Custom content rendered when the image is missing --- From b0f03d006d934e168849f5f58e90a2cabc6346d6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 9 Feb 2026 12:53:58 +0100 Subject: [PATCH 2/5] refactor: replace fallbackColor with direct style properties --- android/src/main/java/voltra/generated/ShortNames.kt | 1 - .../voltra/glance/renderers/TextAndImageRenderers.kt | 11 +---------- .../models/parameters/AndroidImageParameters.kt | 2 -- data/components.json | 11 ----------- ios/shared/ShortNames.swift | 1 - ios/ui/Generated/Parameters/ImageParameters.swift | 5 ----- ios/ui/Views/VoltraImage.swift | 10 +++------- src/jsx/__tests__/Image.node.test.tsx | 5 ----- src/jsx/props/AndroidImage.ts | 2 -- src/jsx/props/Image.ts | 2 -- src/payload/short-names.ts | 2 -- website/docs/android/components/visual.md | 12 +++++++++++- website/docs/ios/components/visual.md | 12 +++++++++++- 13 files changed, 26 insertions(+), 50 deletions(-) diff --git a/android/src/main/java/voltra/generated/ShortNames.kt b/android/src/main/java/voltra/generated/ShortNames.kt index ef848f02..82bf6062 100644 --- a/android/src/main/java/voltra/generated/ShortNames.kt +++ b/android/src/main/java/voltra/generated/ShortNames.kt @@ -49,7 +49,6 @@ object ShortNames { "end" to "endAtMs", "ep" to "endPoint", "fb" to "fallback", - "fbc" to "fallbackColor", "fmh" to "fillMaxHeight", "fmw" to "fillMaxWidth", "fsh" to "fixedSizeHorizontal", diff --git a/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt b/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt index 4fe5724b..ed3aa7af 100644 --- a/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt +++ b/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt @@ -102,16 +102,7 @@ fun RenderImage( RenderNode(fallbackNode) } } else { - val fallbackColorProp = (element.p?.get("fallbackColor") ?: element.p?.get("fbc")) as? String - val fallbackColor = - JSColorParser.parse(fallbackColorProp) ?: JSColorParser.parse("#E0E0E0") - val backgroundModifier = - if (fallbackColor != null) { - finalModifier.background(ColorProvider(fallbackColor)) - } else { - finalModifier - } - androidx.glance.layout.Box(modifier = backgroundModifier) {} + androidx.glance.layout.Box(modifier = finalModifier) {} } } } diff --git a/android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt b/android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt index ecb9862f..9c808ec4 100644 --- a/android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt +++ b/android/src/main/java/voltra/models/parameters/AndroidImageParameters.kt @@ -19,6 +19,4 @@ data class AndroidImageParameters( val source: String, /** Resizing mode */ val resizeMode: String? = null, - /** Background color used when the image is missing */ - val fallbackColor: String? = null, ) diff --git a/data/components.json b/data/components.json index b858ea68..67c1496b 100644 --- a/data/components.json +++ b/data/components.json @@ -92,7 +92,6 @@ "letterSpacing": "ls", "fontVariant": "fvar", "fallback": "fb", - "fallbackColor": "fbc", "width": "w", "opacity": "op", "overflow": "ov", @@ -313,11 +312,6 @@ "default": "cover", "description": "How the image should be resized to fit its container" }, - "fallbackColor": { - "type": "string", - "optional": true, - "description": "Background color used when the image is missing" - }, "fallback": { "type": "component", "optional": true, @@ -344,11 +338,6 @@ "default": "cover", "description": "Resizing mode" }, - "fallbackColor": { - "type": "string", - "optional": true, - "description": "Background color used when the image is missing" - }, "fallback": { "type": "component", "optional": true, diff --git a/ios/shared/ShortNames.swift b/ios/shared/ShortNames.swift index 0fe0d3f1..546abd79 100644 --- a/ios/shared/ShortNames.swift +++ b/ios/shared/ShortNames.swift @@ -46,7 +46,6 @@ public enum ShortNames { "end": "endAtMs", "ep": "endPoint", "fb": "fallback", - "fbc": "fallbackColor", "fmh": "fillMaxHeight", "fmw": "fillMaxWidth", "fsh": "fixedSizeHorizontal", diff --git a/ios/ui/Generated/Parameters/ImageParameters.swift b/ios/ui/Generated/Parameters/ImageParameters.swift index 6b97c9f2..17fcd390 100644 --- a/ios/ui/Generated/Parameters/ImageParameters.swift +++ b/ios/ui/Generated/Parameters/ImageParameters.swift @@ -16,19 +16,14 @@ public struct ImageParameters: ComponentParameters { /// How the image should be resized to fit its container public let resizeMode: String - /// Background color used when the image is missing - public let fallbackColor: String? - enum CodingKeys: String, CodingKey { case source case resizeMode - case fallbackColor } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) source = try container.decodeIfPresent(String.self, forKey: .source) resizeMode = try container.decodeIfPresent(String.self, forKey: .resizeMode) ?? "cover" - fallbackColor = try container.decodeIfPresent(String.self, forKey: .fallbackColor) } } diff --git a/ios/ui/Views/VoltraImage.swift b/ios/ui/Views/VoltraImage.swift index e82a8ce7..94b5a59c 100644 --- a/ios/ui/Views/VoltraImage.swift +++ b/ios/ui/Views/VoltraImage.swift @@ -97,15 +97,11 @@ public struct VoltraImage: VoltraView { } else { let fallbackNode = element.componentProp("fallback") if !fallbackNode.isEmpty { - fallbackNode + Color.clear + .overlay(fallbackNode) .applyStyle(element.style) } else { - let fallbackColor = - JSColorParser.parse(params.fallbackColor) ?? - JSColorParser.parse("#E0E0E0") ?? - Color.gray - Rectangle() - .fill(fallbackColor) + Color.clear .applyStyle(element.style) } } diff --git a/src/jsx/__tests__/Image.node.test.tsx b/src/jsx/__tests__/Image.node.test.tsx index c999b1a4..0b856e78 100644 --- a/src/jsx/__tests__/Image.node.test.tsx +++ b/src/jsx/__tests__/Image.node.test.tsx @@ -29,11 +29,6 @@ describe('Image Component', () => { expect(output.p?.src).toBeUndefined() }) - test('fallbackColor', () => { - const output = renderVoltraVariantToJson() - expect(output.p.fbc).toBe('#E0E0E0') - }) - test('fallback node', () => { const output = renderVoltraVariantToJson(Missing} />) expect(output.p.fb).toBeDefined() diff --git a/src/jsx/props/AndroidImage.ts b/src/jsx/props/AndroidImage.ts index 7d524516..c4b7009f 100644 --- a/src/jsx/props/AndroidImage.ts +++ b/src/jsx/props/AndroidImage.ts @@ -11,8 +11,6 @@ export type AndroidImageProps = VoltraBaseProps & { source: Record /** Resizing mode */ resizeMode?: 'cover' | 'contain' | 'stretch' | 'repeat' | 'center' - /** Background color used when the image is missing */ - fallbackColor?: string /** Custom fallback content rendered when the image is missing */ fallback?: ReactNode } diff --git a/src/jsx/props/Image.ts b/src/jsx/props/Image.ts index 15852ffa..9b8c87b9 100644 --- a/src/jsx/props/Image.ts +++ b/src/jsx/props/Image.ts @@ -11,8 +11,6 @@ export type ImageProps = VoltraBaseProps & { source?: Record /** How the image should be resized to fit its container */ resizeMode?: 'cover' | 'contain' | 'stretch' | 'repeat' | 'center' - /** Background color used when the image is missing */ - fallbackColor?: string /** Custom fallback content rendered when the image is missing */ fallback?: ReactNode } diff --git a/src/payload/short-names.ts b/src/payload/short-names.ts index b8a2a27f..3eae8101 100644 --- a/src/payload/short-names.ts +++ b/src/payload/short-names.ts @@ -42,7 +42,6 @@ export const NAME_TO_SHORT: Record = { endAtMs: 'end', endPoint: 'ep', fallback: 'fb', - fallbackColor: 'fbc', fillMaxHeight: 'fmh', fillMaxWidth: 'fmw', fixedSizeHorizontal: 'fsh', @@ -190,7 +189,6 @@ export const SHORT_TO_NAME: Record = { end: 'endAtMs', ep: 'endPoint', fb: 'fallback', - fbc: 'fallbackColor', fmh: 'fillMaxHeight', fmw: 'fillMaxWidth', fsh: 'fixedSizeHorizontal', diff --git a/website/docs/android/components/visual.md b/website/docs/android/components/visual.md index 1fa71272..7ee55dae 100644 --- a/website/docs/android/components/visual.md +++ b/website/docs/android/components/visual.md @@ -26,9 +26,19 @@ Displays bitmap images from the asset catalog or base64 encoded data. - `contentDescription` (string, optional): Accessibility description for the image. - `alpha` (number, optional): Opacity value from 0.0 to 1.0. - `tintColor` (string, optional): Color to tint the image with. -- `fallbackColor` (string, optional): Background color used when the image is missing (defaults to `#E0E0E0`). - `fallback` (ReactNode, optional): Custom content rendered when the image is missing. +**Styling the fallback:** + +To add a background color when an image is missing, use `backgroundColor` in the `style` prop: + +```jsx + +``` + :::tip Image Preloading For dynamic images from remote URLs, use the [Image Preloading](../development/image-preloading) API to cache them locally for use in widgets. ::: diff --git a/website/docs/ios/components/visual.md b/website/docs/ios/components/visual.md index 397b515a..3791578e 100644 --- a/website/docs/ios/components/visual.md +++ b/website/docs/ios/components/visual.md @@ -31,9 +31,19 @@ Displays bitmap images from the asset catalog or base64 encoded data. - `source` (object, optional): Image source object (`assetName` or `base64`) - `resizeMode` (string, optional): `"cover"`, `"contain"`, `"stretch"`, `"repeat"`, or `"center"` -- `fallbackColor` (string, optional): Background color used when the image is missing (defaults to `#E0E0E0`) - `fallback` (ReactNode, optional): Custom content rendered when the image is missing +**Styling the fallback:** + +To add a background color when an image is missing, use `backgroundColor` in the `style` prop: + +```jsx + +``` + --- ### Symbol From ba316c6360a0701fe1a12834b147b4487d48722b Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 9 Feb 2026 19:02:03 +0100 Subject: [PATCH 3/5] fix: fallback on Android --- .../voltra/glance/renderers/RenderCommon.kt | 58 --- .../glance/renderers/TextAndImageRenderers.kt | 14 +- .../main/java/voltra/models/VoltraPayload.kt | 49 +++ .../java/voltra/parsing/VoltraDecompressor.kt | 90 +++- example/app.json | 10 + .../app/android-widgets/image-fallback.tsx | 5 + .../app/testing-grounds/image-fallback.tsx | 5 + .../android/AndroidImageFallbackScreen.tsx | 245 +++++++++++ example/screens/android/AndroidScreen.tsx | 7 + .../android/AndroidWidgetPinScreen.tsx | 7 + .../testing-grounds/ImageFallbackScreen.tsx | 400 ++++++++++++++++++ .../testing-grounds/TestingGroundsScreen.tsx | 7 + .../widgets/AndroidImageFallbackWidget.tsx | 318 ++++++++++++++ .../android-image-fallback-initial.tsx | 10 + 14 files changed, 1159 insertions(+), 66 deletions(-) create mode 100644 example/app/android-widgets/image-fallback.tsx create mode 100644 example/app/testing-grounds/image-fallback.tsx create mode 100644 example/screens/android/AndroidImageFallbackScreen.tsx create mode 100644 example/screens/testing-grounds/ImageFallbackScreen.tsx create mode 100644 example/widgets/AndroidImageFallbackWidget.tsx create mode 100644 example/widgets/android-image-fallback-initial.tsx diff --git a/android/src/main/java/voltra/glance/renderers/RenderCommon.kt b/android/src/main/java/voltra/glance/renderers/RenderCommon.kt index 2774f38f..419e8409 100644 --- a/android/src/main/java/voltra/glance/renderers/RenderCommon.kt +++ b/android/src/main/java/voltra/glance/renderers/RenderCommon.kt @@ -126,64 +126,6 @@ fun extractImageProvider(sourceProp: Any?): ImageProvider? { return null } -/** - * Parse a prop value into a VoltraNode (used for component-typed props). - */ -@Suppress("UNCHECKED_CAST") -fun parseVoltraNode(value: Any?): VoltraNode? { - return when (value) { - null -> { - null - } - - is VoltraNode -> { - value - } - - is String -> { - VoltraNode.Text(value) - } - - is Number -> { - VoltraNode.Text(value.toString()) - } - - is Boolean -> { - VoltraNode.Text(value.toString()) - } - - is List<*> -> { - val elements = value.mapNotNull { parseVoltraNode(it) } - if (elements.isEmpty()) null else VoltraNode.Array(elements) - } - - is Map<*, *> -> { - val map = value as Map - val ref = map["\$r"] as? Number - if (ref != null) { - return VoltraNode.Ref(ref.toInt()) - } - - val typeId = map["t"] as? Number ?: return null - val id = map["i"] as? String - val child = parseVoltraNode(map["c"]) - val props = map["p"] as? Map - VoltraNode.Element( - VoltraElement( - t = typeId.toInt(), - i = id, - c = child, - p = props, - ), - ) - } - - else -> { - null - } - } -} - /** * Main dispatcher for rendering any VoltraNode. * Handles Element, Array, Ref, Text, and null cases. diff --git a/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt b/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt index ed3aa7af..2a7db1e6 100644 --- a/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt +++ b/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt @@ -6,18 +6,15 @@ import androidx.glance.Image import androidx.glance.background import androidx.glance.text.Text import androidx.glance.unit.ColorProvider -import com.google.gson.Gson import voltra.glance.LocalVoltraRenderContext import voltra.glance.applyClickableIfNeeded import voltra.glance.resolveAndApplyStyle import voltra.models.VoltraElement import voltra.models.VoltraNode +import voltra.models.componentProp import voltra.styling.JSColorParser import voltra.styling.toGlanceTextStyle -private const val TAG = "TextAndImageRenderers" -private val gson = Gson() - @Composable fun RenderText( element: VoltraElement, @@ -85,6 +82,7 @@ fun RenderImage( val imageProvider = extractImageProvider(element.p?.get("source")) + if (imageProvider != null) { Image( provider = imageProvider, @@ -95,8 +93,12 @@ fun RenderImage( alpha = alpha, ) } else { - val fallbackProp = element.p?.get("fallback") ?: element.p?.get("fb") - val fallbackNode = parseVoltraNode(fallbackProp) + val fallbackNode = element.componentProp( + "fallback", + renderContext.sharedStyles, + renderContext.sharedElements, + ) + if (fallbackNode != null) { androidx.glance.layout.Box(modifier = finalModifier) { RenderNode(fallbackNode) diff --git a/android/src/main/java/voltra/models/VoltraPayload.kt b/android/src/main/java/voltra/models/VoltraPayload.kt index b41010b9..0fbb40b1 100644 --- a/android/src/main/java/voltra/models/VoltraPayload.kt +++ b/android/src/main/java/voltra/models/VoltraPayload.kt @@ -51,3 +51,52 @@ sealed class VoltraNode { val text: String, ) : VoltraNode() } + +/** + * Resolve a raw prop value into a VoltraNode with shared context. + * Unlike parseVoltraNode, this eagerly resolves $r references (matching iOS behavior). + */ +@Suppress("UNCHECKED_CAST") +fun resolveToVoltraNode( + value: Any?, + sharedStyles: List>?, + sharedElements: List?, +): VoltraNode? { + return when (value) { + null -> null + is VoltraNode -> value + is String -> VoltraNode.Text(value) + is Number -> VoltraNode.Text(value.toString()) + is Boolean -> VoltraNode.Text(value.toString()) + is List<*> -> { + val elements = value.mapNotNull { resolveToVoltraNode(it, sharedStyles, sharedElements) } + if (elements.isEmpty()) null else VoltraNode.Array(elements) + } + is Map<*, *> -> { + val map = value as Map + val ref = map["\$r"] as? Number + if (ref != null) { + return sharedElements?.getOrNull(ref.toInt()) + } + val typeId = map["t"] as? Number ?: return null + val id = map["i"] as? String + val child = resolveToVoltraNode(map["c"], sharedStyles, sharedElements) + val props = map["p"] as? Map + VoltraNode.Element(VoltraElement(t = typeId.toInt(), i = id, c = child, p = props)) + } + else -> null + } +} + +/** + * Extract a ReactNode-typed prop from this element, resolving it with shared context. + * Mirrors iOS's VoltraElement.componentProp(_:). + */ +fun VoltraElement.componentProp( + propName: String, + sharedStyles: List>?, + sharedElements: List?, +): VoltraNode? { + val value = p?.get(propName) ?: return null + return resolveToVoltraNode(value, sharedStyles, sharedElements) +} diff --git a/android/src/main/java/voltra/parsing/VoltraDecompressor.kt b/android/src/main/java/voltra/parsing/VoltraDecompressor.kt index fca1743b..fb403b4a 100644 --- a/android/src/main/java/voltra/parsing/VoltraDecompressor.kt +++ b/android/src/main/java/voltra/parsing/VoltraDecompressor.kt @@ -1,5 +1,7 @@ package voltra.parsing +import android.util.Log +import com.google.gson.Gson import voltra.generated.ShortNames import voltra.models.* @@ -8,6 +10,8 @@ import voltra.models.* * This should be run as the first step after JSON parsing. */ object VoltraDecompressor { + private const val TAG = "VoltraDecompressor" + private val gson = Gson() /** * Decompress the entire payload recursively. */ @@ -44,8 +48,30 @@ object VoltraDecompressor { val expandedKey = ShortNames.expand(key) val expandedValue = when (value) { - is Map<*, *> -> decompressMap(value as Map) - is List<*> -> value.map { if (it is Map<*, *>) decompressMap(it as Map) else it } + is Map<*, *> -> { + val mapValue = value as Map + // Detect if this is a VoltraElement structure + if (mapValue["t"] is Number) { + Log.d(TAG, "decompressMap: Detected element structure in prop '$key' (expanded: '$expandedKey')") + decompressElementStructure(mapValue) + } else { + decompressMap(mapValue) + } + } + is List<*> -> value.map { + when (it) { + is Map<*, *> -> { + val mapItem = it as Map + // Detect elements in lists too + if (mapItem["t"] is Number) { + decompressElementStructure(mapItem) + } else { + decompressMap(mapItem) + } + } + else -> it + } + } else -> value } result[expandedKey] = expandedValue @@ -53,4 +79,64 @@ object VoltraDecompressor { return result } + + /** + * Decompress a VoltraElement structure within props without expanding its structure field keys. + * Structure keys like "t", "i", "c" are preserved, but nested props and children are processed. + */ + @Suppress("UNCHECKED_CAST") + private fun decompressElementStructure(map: Map): Map { + Log.d(TAG, "decompressElementStructure: Processing element with type=${map["t"]}") + val result = map.toMutableMap() + + // Decompress the "p" (props) field - expand prop keys + if (map["p"] is Map<*, *>) { + val originalProps = map["p"] as Map + Log.d(TAG, "decompressElementStructure: Original props keys: ${originalProps.keys}") + val decompressedProps = decompressMap(originalProps) + Log.d(TAG, "decompressElementStructure: Decompressed props keys: ${decompressedProps.keys}") + result["p"] = decompressedProps + } + + // Decompress the "c" (children) field recursively + val childValue = map["c"] + if (childValue != null) { + result["c"] = decompressNodeValue(childValue) + } + + return result + } + + /** + * Decompress a value that represents a VoltraNode (element, array, text, or ref). + */ + @Suppress("UNCHECKED_CAST") + private fun decompressNodeValue(value: Any): Any { + return when (value) { + is Map<*, *> -> { + val mapValue = value as Map + // Check if it's an element structure + if (mapValue["t"] is Number) { + decompressElementStructure(mapValue) + } else { + // Could be a ref ($r) or other map - don't expand keys for refs + mapValue + } + } + is List<*> -> { + // Array of nodes - recursively decompress each item + value.map { item -> + if (item != null) { + decompressNodeValue(item) + } else { + item + } + } + } + else -> { + // Text nodes (strings, numbers) or null - pass through + value + } + } + } } diff --git a/example/app.json b/example/app.json index b061383c..0fa6653c 100644 --- a/example/app.json +++ b/example/app.json @@ -80,6 +80,16 @@ "targetCellHeight": 2, "resizeMode": "horizontal|vertical", "widgetCategory": "home_screen" + }, + { + "id": "image_fallback", + "displayName": "Image Fallback Widget", + "description": "Test image fallback with backgroundColor from styles", + "targetCellWidth": 2, + "targetCellHeight": 2, + "resizeMode": "horizontal|vertical", + "widgetCategory": "home_screen", + "initialStatePath": "./widgets/android-image-fallback-initial.tsx" } ] }, diff --git a/example/app/android-widgets/image-fallback.tsx b/example/app/android-widgets/image-fallback.tsx new file mode 100644 index 00000000..b960358a --- /dev/null +++ b/example/app/android-widgets/image-fallback.tsx @@ -0,0 +1,5 @@ +import AndroidImageFallbackScreen from '~/screens/android/AndroidImageFallbackScreen' + +export default function AndroidImageFallbackIndex() { + return +} diff --git a/example/app/testing-grounds/image-fallback.tsx b/example/app/testing-grounds/image-fallback.tsx new file mode 100644 index 00000000..7e70640f --- /dev/null +++ b/example/app/testing-grounds/image-fallback.tsx @@ -0,0 +1,5 @@ +import ImageFallbackScreen from '~/screens/testing-grounds/ImageFallbackScreen' + +export default function ImageFallbackIndex() { + return +} diff --git a/example/screens/android/AndroidImageFallbackScreen.tsx b/example/screens/android/AndroidImageFallbackScreen.tsx new file mode 100644 index 00000000..acc2c9d6 --- /dev/null +++ b/example/screens/android/AndroidImageFallbackScreen.tsx @@ -0,0 +1,245 @@ +import { useRouter } from 'expo-router' +import React, { useState } from 'react' +import { Alert, Platform, ScrollView, StyleSheet, Text, View } from 'react-native' +import { requestPinAndroidWidget, updateAndroidWidget } from 'voltra/android/client' + +import { Button } from '~/components/Button' +import { Card } from '~/components/Card' +import { AndroidImageFallbackWidget } from '~/widgets/AndroidImageFallbackWidget' + +const WIDGET_ID = 'image_fallback' + +type ExampleType = 'colors' | 'styled' | 'transparent' | 'custom' | 'mixed' + +const EXAMPLES: Array<{ id: ExampleType; title: string; description: string }> = [ + { + id: 'colors', + title: 'Background Colors', + description: 'Four missing images with different background colors (red, orange, green, blue)', + }, + { + id: 'styled', + title: 'Combined Styles', + description: 'Missing image with backgroundColor and borderRadius (Android 12+)', + }, + { + id: 'transparent', + title: 'Transparent Fallback', + description: 'Missing image with no backgroundColor - parent color shows through', + }, + { + id: 'custom', + title: 'Custom Fallback', + description: 'Missing image with custom fallback component (emoji and text)', + }, + { + id: 'mixed', + title: 'Image Grid', + description: 'Multiple missing images in a grid layout with different colors', + }, +] + +export default function AndroidImageFallbackScreen() { + const router = useRouter() + const [selectedExample, setSelectedExample] = useState('colors') + const [isUpdating, setIsUpdating] = useState(false) + const [isPinning, setIsPinning] = useState(false) + + const handlePinWidget = async () => { + if (Platform.OS !== 'android') { + Alert.alert('Not Available', 'Widget pinning is only available on Android devices.') + return + } + + setIsPinning(true) + try { + const success = await requestPinAndroidWidget(WIDGET_ID, { + previewWidth: 250, + previewHeight: 150, + }) + + if (success) { + Alert.alert( + 'Success', + 'Pin request sent! Check your home screen to complete the pinning. Then use the buttons below to change examples.' + ) + } else { + Alert.alert( + 'Not Supported', + 'Widget pinning is not available on this device. This feature requires Android 8.0 (API level 26) or higher.' + ) + } + } catch (error: any) { + const errorMessage = error?.message || String(error) + Alert.alert('Error', `Failed to request widget pin: ${errorMessage}`) + console.error('Widget pin error:', error) + } finally { + setIsPinning(false) + } + } + + const handleUpdateWidget = async (example: ExampleType) => { + if (Platform.OS !== 'android') { + Alert.alert('Not Available', 'Widget updates are only available on Android devices.') + return + } + + setSelectedExample(example) + setIsUpdating(true) + try { + await updateAndroidWidget(WIDGET_ID, [ + { + size: { width: 250, height: 150 }, + content: , + }, + ]) + Alert.alert('Success', `Widget updated to show: ${EXAMPLES.find((e) => e.id === example)?.title}`) + } catch (error: any) { + const errorMessage = error?.message || String(error) + Alert.alert('Error', `Failed to update widget: ${errorMessage}`) + console.error('Widget update error:', error) + } finally { + setIsUpdating(false) + } + } + + return ( + + + Image Fallback Widget (Android) + + Test the new image fallback behavior on Android widgets. Pin the widget to your home screen, then use the + buttons below to switch between different examples. + + + + 1. Pin Widget to Home Screen + + First, pin the widget to your home screen. You can then use the buttons below to update it with different + examples. + + +