Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
649 changes: 649 additions & 0 deletions android/src/main/java/voltra/glance/renderers/ChartBitmapRenderer.kt

Large diffs are not rendered by default.

152 changes: 152 additions & 0 deletions android/src/main/java/voltra/glance/renderers/ChartRenderers.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package voltra.glance.renderers

import android.graphics.drawable.Icon
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.layout.ContentScale
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.width
import voltra.glance.LocalVoltraRenderContext
import voltra.glance.applyClickableIfNeeded
import voltra.glance.resolveAndApplyStyle
import voltra.models.VoltraElement
import voltra.styling.JSColorParser
import voltra.styling.SizeValue

private const val TAG = "ChartRenderers"

private const val DEFAULT_CHART_WIDTH_DP = 300
private const val DEFAULT_CHART_HEIGHT_DP = 200
private const val MAX_BITMAP_PIXELS = 600

@Composable
fun RenderChart(
element: VoltraElement,
modifier: GlanceModifier? = null,
) {
val renderContext = LocalVoltraRenderContext.current
val (baseModifier, _) = resolveAndApplyStyle(element.p, renderContext.sharedStyles)
val finalModifier =
applyClickableIfNeeded(
modifier ?: baseModifier,
element.p,
element.i,
renderContext.widgetId,
element.t,
element.hashCode(),
)

val marksJson = element.p?.get("marks") as? String
if (marksJson.isNullOrEmpty()) {
Log.w(TAG, "Chart element has no marks data")
return
}

val marks = parseMarksJson(marksJson)
if (marks.isEmpty()) {
Log.w(TAG, "Chart element has no valid marks after parsing")
return
}

val foregroundStyleScale = parseForegroundStyleScale(element.p?.get("foregroundStyleScale") as? String)

val xAxisVisible = (element.p?.get("xAxisVisibility") as? String) != "hidden"
val yAxisVisible = (element.p?.get("yAxisVisibility") as? String) != "hidden"

val (_, compositeStyle) = resolveAndApplyStyle(element.p, renderContext.sharedStyles)
val styleWidth = compositeStyle?.layout?.width
val styleHeight = compositeStyle?.layout?.height

val widthIsFill = styleWidth is SizeValue.Fill
val heightIsFill = styleHeight is SizeValue.Fill
val hasSectors = marks.any { it.type == "sector" }
val defaultWidth =
if (hasSectors && heightIsFill &&
widthIsFill
) {
DEFAULT_CHART_HEIGHT_DP
} else {
DEFAULT_CHART_WIDTH_DP
}
val chartWidthDp =
when (styleWidth) {
is SizeValue.Fixed -> styleWidth.value.value.toInt()
else -> defaultWidth
}
val chartHeightDp =
when (styleHeight) {
is SizeValue.Fixed -> styleHeight.value.value.toInt()
else -> DEFAULT_CHART_HEIGHT_DP
}

val scale =
(MAX_BITMAP_PIXELS.toFloat() / maxOf(chartWidthDp, chartHeightDp).coerceAtLeast(1))
.coerceAtMost(1.5f)
val bitmapWidth = (chartWidthDp * scale).toInt().coerceAtLeast(1)
val bitmapHeight = (chartHeightDp * scale).toInt().coerceAtLeast(1)

val bitmap =
renderChartBitmap(
marks = marks,
width = bitmapWidth,
height = bitmapHeight,
foregroundStyleScale = foregroundStyleScale,
xAxisVisible = xAxisVisible,
yAxisVisible = yAxisVisible,
dpScale = scale,
)

var sizeModifier = finalModifier
sizeModifier =
sizeModifier.then(
when {
widthIsFill -> GlanceModifier.fillMaxWidth()
else -> GlanceModifier.width(chartWidthDp.dp)
},
)
sizeModifier =
sizeModifier.then(
when {
heightIsFill -> GlanceModifier.fillMaxHeight()
else -> GlanceModifier.height(chartHeightDp.dp)
},
)

val icon = Icon.createWithBitmap(bitmap)

Image(
provider = ImageProvider(icon),
contentDescription = "Chart",
contentScale = if (hasSectors) ContentScale.Fit else ContentScale.FillBounds,
modifier = sizeModifier,
)
}

private fun parseForegroundStyleScale(json: String?): Map<String, Int>? {
if (json.isNullOrEmpty()) return null
return try {
val gson = com.google.gson.Gson()
val type = object : com.google.gson.reflect.TypeToken<List<List<String>>>() {}.type
val pairs: List<List<String>> = gson.fromJson(json, type)
val map = mutableMapOf<String, Int>()
for (pair in pairs) {
if (pair.size >= 2) {
val color = JSColorParser.parse(pair[1])
if (color != null) {
map[pair[0]] = color.toArgb()
}
}
}
if (map.isEmpty()) null else map
} catch (e: Exception) {
Log.w(TAG, "Failed to parse foregroundStyleScale", e)
null
}
}
7 changes: 5 additions & 2 deletions android/src/main/java/voltra/glance/renderers/RenderCommon.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package voltra.glance.renderers
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Composable
Expand Down Expand Up @@ -103,7 +104,7 @@ fun extractImageProvider(sourceProp: Any?): ImageProvider? {
val uri = Uri.parse(uriString)
context.contentResolver.openInputStream(uri)?.use { stream ->
val bitmap = BitmapFactory.decodeStream(stream)
return ImageProvider(bitmap)
return ImageProvider(Icon.createWithBitmap(bitmap))
}
} catch (e: Exception) {
Log.e(TAG, "Failed to decode preloaded image: $assetName", e)
Expand All @@ -116,7 +117,7 @@ fun extractImageProvider(sourceProp: Any?): ImageProvider? {
val decodedString = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size)
if (bitmap != null) {
return ImageProvider(bitmap)
return ImageProvider(Icon.createWithBitmap(bitmap))
}
} catch (e: Exception) {
Log.e(TAG, "Failed to decode base64 image", e)
Expand Down Expand Up @@ -183,6 +184,7 @@ private fun RenderElement(element: VoltraElement) {
ComponentTypeID.SCAFFOLD -> RenderScaffold(element)
ComponentTypeID.LAZY_COLUMN -> RenderLazyColumn(element)
ComponentTypeID.LAZY_VERTICAL_GRID -> RenderLazyVerticalGrid(element)
ComponentTypeID.CHART -> RenderChart(element)
}
}

Expand Down Expand Up @@ -217,5 +219,6 @@ fun RenderElementWithModifier(
ComponentTypeID.SCAFFOLD -> RenderScaffold(element, modifier)
ComponentTypeID.LAZY_COLUMN -> RenderLazyColumn(element, modifier)
ComponentTypeID.LAZY_VERTICAL_GRID -> RenderLazyVerticalGrid(element, modifier)
ComponentTypeID.CHART -> RenderChart(element, modifier)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// AndroidChartParameters.kt
//
// AUTO-GENERATED from data/components.json
// DO NOT EDIT MANUALLY - Changes will be overwritten
// Schema version: 1.0.0

package voltra.models.parameters

import kotlinx.serialization.Serializable

/**
* Parameters for AndroidChart component
* Android charts component for data visualization
*/
@Serializable
data class AndroidChartParameters(
/** Compact mark data encoded from children by toJSON */
val marks: String? = null,
/** Show or hide the x-axis */
val xAxisVisibility: String? = null,
/** Show or hide the y-axis */
val yAxisVisibility: String? = null,
/** Show or hide the chart legend */
val legendVisibility: String? = null,
/** Map of series name to color string */
val foregroundStyleScale: String? = null,
/** Enable scrolling on the given axis */
val chartScrollableAxes: String? = null,
)
2 changes: 2 additions & 0 deletions android/src/main/java/voltra/payload/ComponentTypeID.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ object ComponentTypeID {
const val SQUARE_ICON_BUTTON = 17
const val TEXT = 18
const val TITLE_BAR = 19
const val CHART = 20

/**
* Get component name from numeric ID
Expand All @@ -58,6 +59,7 @@ object ComponentTypeID {
17 -> "AndroidSquareIconButton"
18 -> "AndroidText"
19 -> "AndroidTitleBar"
20 -> "AndroidChart"
else -> null
}
}
47 changes: 46 additions & 1 deletion data/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -1247,7 +1247,7 @@
},
{
"name": "Chart",
"description": "SwiftUI Charts component for data visualization",
"description": "Charts component for data visualization",
"swiftAvailability": "iOS 16.0, macOS 13.0",
"hasChildren": true,
"parameters": {
Expand Down Expand Up @@ -1288,6 +1288,51 @@
"description": "Enable scrolling on the given axis"
}
}
},
{
"name": "AndroidChart",
"description": "Android charts component for data visualization",
"swiftAvailability": "Not available",
"androidAvailability": "Android 12+",
"hasChildren": true,
"parameters": {
"marks": {
"type": "array",
"optional": true,
"jsonEncoded": true,
"description": "Compact mark data encoded from children by toJSON"
},
"xAxisVisibility": {
"type": "string",
"optional": true,
"enum": ["automatic", "visible", "hidden"],
"description": "Show or hide the x-axis"
},
"yAxisVisibility": {
"type": "string",
"optional": true,
"enum": ["automatic", "visible", "hidden"],
"description": "Show or hide the y-axis"
},
"legendVisibility": {
"type": "string",
"optional": true,
"enum": ["automatic", "visible", "hidden"],
"description": "Show or hide the chart legend"
},
"foregroundStyleScale": {
"type": "object",
"optional": true,
"jsonEncoded": true,
"description": "Map of series name to color string"
},
"chartScrollableAxes": {
"type": "string",
"optional": true,
"enum": ["horizontal", "vertical"],
"description": "Enable scrolling on the given axis"
}
}
}
]
}
10 changes: 10 additions & 0 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@
"resizeMode": "horizontal|vertical",
"widgetCategory": "home_screen",
"initialStatePath": "./widgets/android-image-fallback-initial.tsx"
},
{
"id": "chart_widget",
"displayName": "Chart Widget",
"description": "Test Chart component",
"targetCellWidth": 3,
"targetCellHeight": 3,
"resizeMode": "horizontal|vertical",
"widgetCategory": "home_screen",
"initialStatePath": "./widgets/android-chart-widget-initial.tsx"
}
]
},
Expand Down
5 changes: 5 additions & 0 deletions example/app/android-widgets/charts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import AndroidChartScreen from '~/screens/android/AndroidChartScreen'

export default function AndroidChartIndex() {
return <AndroidChartScreen />
}
Loading