Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ object Attrs {
const val attrPhxValue = CoreAttrs.attrPhxValue
const val attrPivotFractionX = "pivotFractionX"
const val attrPivotFractionY = "pivotFractionY"
const val attrProgress = "progress"
const val attrPropagateMinConstraints = "propagateMinConstraints"
const val attrQuery = "query"
const val attrOnActiveChanged = "onActiveChanged"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,10 @@ class DocumentParser(
private val screenId: String,
private val composableNodeFactory: BaseComposableNodeFactory
) : DocumentChangeHandler {
private lateinit var document: Document

init {
newDocument()
}
private var document: Document? = null

fun newDocument() {
document = Document.empty().apply {
setEventHandler(this@DocumentParser)
}
document = null
}

override fun handleDocumentChange(
Expand All @@ -38,30 +32,41 @@ class DocumentParser(

when (changeType) {
ChangeType.CHANGE -> {
Log.i(TAG, "Changed: ${this.document.get(nodeRef)}")
Log.i(TAG, "Changed: $nodeData")
}

ChangeType.ADD -> {
Log.i(TAG, "Added: ${this.document.get(nodeRef)}")
Log.i(TAG, "Added: $nodeData")
}

ChangeType.REMOVE -> {
Log.i(TAG, "Remove: ${this.document.get(nodeRef)}")
Log.i(TAG, "Remove: $nodeData")
}

ChangeType.REPLACE -> {
Log.i(TAG, "Replace: ${this.document.get(nodeRef)}")
Log.i(TAG, "Replace: $nodeData")
}
}
}

fun parseDocumentJson(json: String): ComposableTreeNode {
document.mergeFragmentJson(json)
val currentDoc = document
val doc = if (currentDoc == null) {
// First render: use parseFragmentJson to properly set up templates
Document.parseFragmentJson(json).also {
it.setEventHandler(this@DocumentParser)
document = it
}
} else {
// Subsequent diffs: merge into existing document
currentDoc.mergeFragmentJson(json)
currentDoc
}

return ComposableTreeNode(this.screenId, -1, null, id = "rootNode").apply {
// Walk through the DOM and create a ComposableTreeNode tree
Log.i(TAG, "walkThroughDOM start")
walkThroughDOM(document, document.root(), this)
walkThroughDOM(doc, doc.root(), this)
Log.i(TAG, "walkThroughDOM complete")
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.phoenixframework.liveview.foundation.domain

import android.util.Log
import com.google.gson.JsonParser as GsonJsonParser
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
Expand Down Expand Up @@ -257,10 +258,8 @@ class LiveViewCoordinator(
}

private fun getJsonFieldAsString(field: String, json: String): String {
return json.run {
val jsonField = "\"${field}\":"
substring(indexOf(jsonField) + jsonField.length, lastIndex)
}
val jsonObject = GsonJsonParser.parseString(json).asJsonObject
return jsonObject.get(field).toString()
}

private fun handleNavigation(message: Message) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ abstract class BaseModifiersParser {
}

val mapContext = mapExprContext.map()
val mapEntryContext = mapContext.map_entries()?.map_entry()?.map { mapEntryContext ->
val mapEntryContext = mapContext.map_entries()?.map_entry()
?.filter { it.expression(1) is ListExprContext }
?.map { mapEntryContext ->
// The map key is the style name and the map value contain the list of modifiers
val styleName = mapEntryContext.expression(0)?.text
?.removePrefix("\"")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import org.phoenixframework.liveview.constants.ModifierArgs.argWidth
import org.phoenixframework.liveview.constants.ModifierTypes.typeBorderStroke
import org.phoenixframework.liveview.constants.ModifierTypes.typeColor
import org.phoenixframework.liveview.constants.ModifierTypes.typeShape
import org.phoenixframework.liveview.constants.ShapeValues
import org.phoenixframework.liveview.foundation.ui.modifiers.ModifierDataAdapter

fun Modifier.borderFromStyle(arguments: List<ModifierDataAdapter.ArgumentData>): Modifier {
Expand Down Expand Up @@ -53,7 +54,10 @@ fun Modifier.borderFromStyle(arguments: List<ModifierDataAdapter.ArgumentData>):
borderArgument.type == typeColor ->
borderColor = colorFromArgument(borderArgument)

borderArgument.type == typeShape ->
borderArgument.type == typeShape ||
borderArgument.type == ShapeValues.roundedCorner ||
borderArgument.type == ShapeValues.circle ||
borderArgument.type == ShapeValues.rectangle ->
borderShape = shapeFromArgument(borderArgument)

borderArgument.isDot || borderArgument.isAtom -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.ImmutableList
import org.phoenixframework.liveview.constants.Attrs.attrColor
import org.phoenixframework.liveview.constants.Attrs.attrProgress
import org.phoenixframework.liveview.constants.Attrs.attrStrokeCap
import org.phoenixframework.liveview.constants.Attrs.attrStrokeWidth
import org.phoenixframework.liveview.constants.Attrs.attrTrackColor
Expand Down Expand Up @@ -46,22 +47,48 @@ internal class ProgressIndicatorView private constructor(props: Properties) :
val strokeWidth = props.strokeWidth
val trackColor = props.trackColor
val strokeCap = props.strokeCap
val progress = props.progress

if (composableNode?.node?.tag == ComposableTypes.linearProgressIndicator) {
LinearProgressIndicator(
modifier = props.commonProps.modifier,
color = color ?: ProgressIndicatorDefaults.linearColor,
trackColor = trackColor ?: ProgressIndicatorDefaults.linearTrackColor,
strokeCap = strokeCap ?: ProgressIndicatorDefaults.LinearStrokeCap,
)
if (progress != null) {
// Determinate progress indicator
LinearProgressIndicator(
progress = { progress },
modifier = props.commonProps.modifier,
color = color ?: ProgressIndicatorDefaults.linearColor,
trackColor = trackColor ?: ProgressIndicatorDefaults.linearTrackColor,
strokeCap = strokeCap ?: ProgressIndicatorDefaults.LinearStrokeCap,
)
} else {
// Indeterminate progress indicator
LinearProgressIndicator(
modifier = props.commonProps.modifier,
color = color ?: ProgressIndicatorDefaults.linearColor,
trackColor = trackColor ?: ProgressIndicatorDefaults.linearTrackColor,
strokeCap = strokeCap ?: ProgressIndicatorDefaults.LinearStrokeCap,
)
}
} else {
CircularProgressIndicator(
modifier = props.commonProps.modifier,
color = color ?: ProgressIndicatorDefaults.circularColor,
trackColor = trackColor ?: ProgressIndicatorDefaults.circularTrackColor,
strokeCap = strokeCap ?: ProgressIndicatorDefaults.CircularIndeterminateStrokeCap,
strokeWidth = strokeWidth ?: ProgressIndicatorDefaults.CircularStrokeWidth,
)
if (progress != null) {
// Determinate progress indicator
CircularProgressIndicator(
progress = { progress },
modifier = props.commonProps.modifier,
color = color ?: ProgressIndicatorDefaults.circularColor,
trackColor = trackColor ?: ProgressIndicatorDefaults.circularTrackColor,
strokeCap = strokeCap ?: ProgressIndicatorDefaults.CircularDeterminateStrokeCap,
strokeWidth = strokeWidth ?: ProgressIndicatorDefaults.CircularStrokeWidth,
)
} else {
// Indeterminate progress indicator
CircularProgressIndicator(
modifier = props.commonProps.modifier,
color = color ?: ProgressIndicatorDefaults.circularColor,
trackColor = trackColor ?: ProgressIndicatorDefaults.circularTrackColor,
strokeCap = strokeCap ?: ProgressIndicatorDefaults.CircularIndeterminateStrokeCap,
strokeWidth = strokeWidth ?: ProgressIndicatorDefaults.CircularStrokeWidth,
)
}
}
}

Expand All @@ -71,6 +98,7 @@ internal class ProgressIndicatorView private constructor(props: Properties) :
val trackColor: Color? = null,
val strokeCap: StrokeCap? = null,
val strokeWidth: Dp? = null,
val progress: Float? = null,
override val commonProps: CommonComposableProperties = CommonComposableProperties(),
) : ComposableProperties

Expand All @@ -91,6 +119,7 @@ internal class ProgressIndicatorView private constructor(props: Properties) :
attributes.fold(Properties()) { props, attribute ->
when (attribute.name) {
attrColor -> color(props, attribute.value)
attrProgress -> progress(props, attribute.value)
attrStrokeCap -> strokeCap(props, attribute.value)
attrStrokeWidth -> strokeWidth(props, attribute.value)
attrTrackColor -> trackColor(props, attribute.value)
Expand Down Expand Up @@ -121,6 +150,29 @@ internal class ProgressIndicatorView private constructor(props: Properties) :
} else props
}

/**
* Progress value for determinate progress indicator.
* Value should be between 0.0 and 1.0.
* If not set, the progress indicator will be indeterminate.
* ```
* <LinearProgressIndicator progress='0.5' />
* <CircularProgressIndicator progress='0.75' />
* ```
* @param progress Progress value between 0.0 and 1.0
*/
private fun progress(props: Properties, progress: String): Properties {
// Try parsing the progress value, handling potential locale issues
val progressValue = progress.trim().toFloatOrNull()
?: progress.trim().replace(',', '.').toFloatOrNull()

return if (progressValue != null) {
props.copy(progress = progressValue.coerceIn(0f, 1f))
} else {
android.util.Log.w("ProgressIndicator", "Failed to parse progress value: '$progress'")
props
}
}

/**
* Color of the track behind the indicator, visible when the progress has not reached the
* area of the overall indicator yet.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package org.phoenixframework.liveview.test.foundation.data.mappers

import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.phoenixframework.liveview.foundation.data.mappers.DocumentParser
import org.phoenixframework.liveview.foundation.ui.registry.BaseComposableNodeFactory
import org.phoenixframework.liveview.foundation.ui.registry.ComposableRegistry

/**
* Tests for DocumentParser to verify LiveView fragment parsing.
*
* LiveView uses a compressed template format where:
* - "s" contains static template parts (as an array of strings)
* - Numeric keys (0, 1, 2...) contain dynamic values that are interpolated between statics
* - "d" contains dynamics for comprehensions
* - "c" contains components
*/
@RunWith(AndroidJUnit4::class)
class DocumentParserTest {

private val testFactory = object : BaseComposableNodeFactory(ComposableRegistry()) {}

/**
* Tests parsing a simple static template.
* Format: {"s": ["<Text>Hello, Jetpack!</Text>"]}
*/
@Test
fun parseSimpleStaticTemplate() {
val parser = DocumentParser("test-screen", testFactory)

val json = """{"s": ["<Text>Hello, Jetpack!</Text>"]}"""

val result = parser.parseDocumentJson(json)

assertNotNull(result)
assertEquals("rootNode", result.id)
}

/**
* Tests parsing a template with dynamic content.
* Format: {"0": "World", "s": ["<Text>Hello, ", "!</Text>"]}
* Should render as: <Text>Hello, World!</Text>
*/
@Test
fun parseTemplateWithDynamic() {
val parser = DocumentParser("test-screen", testFactory)

val json = """{"0": "World", "s": ["<Text>Hello, ", "!</Text>"]}"""

val result = parser.parseDocumentJson(json)

assertNotNull(result)
assertEquals("rootNode", result.id)
}

/**
* Tests that subsequent diffs work after initial render.
*/
@Test
fun parseDiffAfterInitialRender() {
val parser = DocumentParser("test-screen", testFactory)

// Initial render with dynamic content
val initialJson = """{"0": "0", "s": ["<Column><Text>Counter: ", "</Text></Column>"]}"""
parser.parseDocumentJson(initialJson)

// Diff update - just changes the dynamic value
val diffJson = """{"0": "1"}"""
val result = parser.parseDocumentJson(diffJson)

assertNotNull(result)
}

/**
* Tests that newDocument() resets state and allows a fresh initial render.
*/
@Test
fun newDocumentResetsState() {
val parser = DocumentParser("test-screen", testFactory)

// First initial render
val json1 = """{"0": "first", "s": ["<Text>", "</Text>"]}"""
parser.parseDocumentJson(json1)

// Reset
parser.newDocument()

// Second initial render (should use parseFragmentJson, not mergeFragmentJson)
val json2 = """{"0": "second", "s": ["<Text>", "</Text>"]}"""
val result = parser.parseDocumentJson(json2)

assertNotNull(result)
}

/**
* Tests comprehension format with dynamics.
* Based on real LiveView output for list rendering.
*/
@Test
fun parseComprehensionWithDynamics() {
val parser = DocumentParser("test-screen", testFactory)

// Format with comprehension (d = dynamics array)
val json = """{
"0": {
"d": [["Item 1"], ["Item 2"]],
"s": ["<Text>", "</Text>"]
},
"s": ["<Column>", "</Column>"]
}"""

val result = parser.parseDocumentJson(json)

assertNotNull(result)
}
}
Loading