diff --git a/android/src/main/java/voltra/generated/ShortNames.kt b/android/src/main/java/voltra/generated/ShortNames.kt index 43cea8cf..e883a51e 100644 --- a/android/src/main/java/voltra/generated/ShortNames.kt +++ b/android/src/main/java/voltra/generated/ShortNames.kt @@ -50,6 +50,7 @@ object ShortNames { "en" to "enabled", "end" to "endAtMs", "ep" to "endPoint", + "flb" to "fallback", "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 83060e71..f3f6d95e 100644 --- a/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt +++ b/android/src/main/java/voltra/glance/renderers/TextAndImageRenderers.kt @@ -3,20 +3,18 @@ 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 com.google.gson.Gson +import androidx.glance.unit.ColorProvider 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.models.componentProp +import voltra.styling.JSColorParser import voltra.styling.toGlanceTextStyle -private const val TAG = "TextAndImageRenderers" -private val gson = Gson() - @Composable fun RenderText( element: VoltraElement, @@ -94,7 +92,20 @@ fun RenderImage( alpha = alpha, ) } else { - androidx.glance.layout.Box(modifier = finalModifier) {} + val fallbackNode = + element.componentProp( + "fallback", + renderContext.sharedStyles, + renderContext.sharedElements, + ) + + if (fallbackNode != null) { + androidx.glance.layout.Box(modifier = finalModifier) { + RenderNode(fallbackNode) + } + } else { + androidx.glance.layout.Box(modifier = finalModifier) {} + } } } diff --git a/android/src/main/java/voltra/models/VoltraPayload.kt b/android/src/main/java/voltra/models/VoltraPayload.kt index b41010b9..f294db0f 100644 --- a/android/src/main/java/voltra/models/VoltraPayload.kt +++ b/android/src/main/java/voltra/models/VoltraPayload.kt @@ -51,3 +51,71 @@ 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..795372a1 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,9 @@ 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,13 +49,108 @@ 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 } - else -> value + 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 } 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 = + 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/data/components.json b/data/components.json index 0fe879b4..5841e5df 100644 --- a/data/components.json +++ b/data/components.json @@ -91,6 +91,7 @@ "color": "c", "letterSpacing": "ls", "fontVariant": "fvar", + "fallback": "flb", "width": "w", "opacity": "op", "overflow": "ov", @@ -318,6 +319,11 @@ "enum": ["cover", "contain", "stretch", "repeat", "center"], "default": "cover", "description": "How the image should be resized to fit its container" + }, + "fallback": { + "type": "component", + "optional": true, + "description": "Custom fallback content rendered when the image is missing" } } }, @@ -339,6 +345,11 @@ "enum": ["cover", "contain", "stretch", "repeat", "center"], "default": "cover", "description": "Resizing mode" + }, + "fallback": { + "type": "component", + "optional": true, + "description": "Custom fallback content rendered when the image is missing" } } }, 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. + + +