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 @@ -4,14 +4,17 @@ import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch

private const val TAG = "NativeTopBar"
Expand All @@ -37,11 +40,35 @@ fun NativeTopBar(
val data = topBarData!!
val backgroundColor = data.backgroundColor?.let { parseColor(it) }
val textColor = data.textColor?.let { parseColor(it) }
val actions = data.children?.mapNotNull { it.data } ?: emptyList()

// Filter out top-level sections as standard action bar icons cannot logically be sections
val topLevelComponents = data.children?.filter { it.type != "top_bar_section" } ?: emptyList()

val handleActionClick: (TopBarActionData) -> Unit = { action ->
Log.d(TAG, "⚡ Action clicked: ${action.label ?: action.id}")
action.url?.let { url ->
if (isExternalUrl(url)) {
Log.d(TAG, "🌐 Opening external URL in browser: $url")
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to open external URL: $url", e)
}
} else {
Log.d(TAG, "📱 Opening internal URL in WebView: $url")
onNavigate(url)
}
}
action.event?.let {
// Dispatch event if specified
Log.d(TAG, "📢 Dispatching event: $it")
}
}

// Split actions into visible (max 3) and overflow
val visibleActions = actions.take(3)
val overflowActions = actions.drop(3)
val visibleComponents = topLevelComponents.take(3)
val overflowComponents = topLevelComponents.drop(3)
val showOverflowMenu = remember { mutableStateOf(false) }

TopAppBar(
Expand Down Expand Up @@ -82,40 +109,45 @@ fun NativeTopBar(
},
actions = {
// Render visible actions (max 3)
visibleActions.forEach { action ->
IconButton(
onClick = {
Log.d(TAG, "⚡ Action clicked: ${action.label ?: action.id}")
action.url?.let { url ->
if (isExternalUrl(url)) {
Log.d(TAG, "🌐 Opening external URL in browser: $url")
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to open external URL: $url", e)
}
} else {
Log.d(TAG, "📱 Opening internal URL in WebView: $url")
onNavigate(url)
}
}
action.event?.let {
// Dispatch event if specified
Log.d(TAG, "📢 Dispatching event: $it")
}
visibleComponents.forEach { component ->
val action = component.data
val actionChildren = action.children ?: emptyList()

if (actionChildren.isNotEmpty()) {
val showActionMenu = remember(action.id) { mutableStateOf(false) }

IconButton(onClick = { showActionMenu.value = true }) {
MaterialIcon(
name = action.icon ?: "menu",
contentDescription = action.label ?: action.id,
tint = textColor ?: MaterialTheme.colorScheme.onSurface
)
}

DropdownMenu(
expanded = showActionMenu.value,
onDismissRequest = { showActionMenu.value = false }
) {
BuildMenuElements(
components = actionChildren,
textColor = textColor,
onDismiss = { showActionMenu.value = false },
handleActionClick = handleActionClick
)
}
} else {
IconButton(onClick = { handleActionClick(action) }) {
MaterialIcon(
name = action.icon ?: "error",
contentDescription = action.label ?: action.id,
tint = textColor ?: MaterialTheme.colorScheme.onSurface
)
}
) {
MaterialIcon(
name = action.icon,
contentDescription = action.label ?: action.id,
tint = textColor ?: MaterialTheme.colorScheme.onSurface
)
}
}

// Overflow menu if more than 3 actions
if (overflowActions.isNotEmpty()) {
if (overflowComponents.isNotEmpty()) {
IconButton(onClick = { showOverflowMenu.value = true }) {
MaterialIcon(
name = "more_vert",
Expand All @@ -128,38 +160,12 @@ fun NativeTopBar(
expanded = showOverflowMenu.value,
onDismissRequest = { showOverflowMenu.value = false }
) {
overflowActions.forEach { action ->
DropdownMenuItem(
text = { Text(action.label ?: action.id) },
onClick = {
showOverflowMenu.value = false
Log.d(TAG, "⚡ Overflow action clicked: ${action.label ?: action.id}")
action.url?.let { url ->
if (isExternalUrl(url)) {
Log.d(TAG, "🌐 Opening external URL in browser: $url")
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to open external URL: $url", e)
}
} else {
Log.d(TAG, "📱 Opening internal URL in WebView: $url")
onNavigate(url)
}
}
action.event?.let {
Log.d(TAG, "📢 Dispatching event: $it")
}
},
leadingIcon = {
MaterialIcon(
name = action.icon,
contentDescription = action.label ?: action.id
)
}
)
}
BuildMenuElements(
components = overflowComponents,
textColor = textColor,
onDismiss = { showOverflowMenu.value = false },
handleActionClick = handleActionClick
)
}
}
},
Expand All @@ -171,10 +177,96 @@ fun NativeTopBar(
)
}

/**
* Recursively builds dropdown menu elements handling Sections and Action groupings.
*/
@Composable
private fun ColumnScope.BuildMenuElements(
components: List<TopBarActionComponent>,
textColor: Color?,
onDismiss: () -> Unit,
handleActionClick: (TopBarActionData) -> Unit
) {
components.forEach { component ->
if (component.type == "top_bar_section") {
val section = component.data

// Section Title - FIXED: Now checks for empty or blank strings
if (!section.title.isNullOrBlank()) {
Text(
text = section.title,
style = MaterialTheme.typography.labelMedium,
color = (textColor ?: MaterialTheme.colorScheme.onSurface).copy(alpha = 0.6f),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}

// Section Children
section.children?.let { children ->
BuildMenuElements(children, textColor, onDismiss, handleActionClick)
}

// Appends a subtle divider after groups for cleaner structure
Divider(color = (textColor ?: MaterialTheme.colorScheme.onSurface).copy(alpha = 0.1f))

} else {
val action = component.data
val hasChildren = !action.children.isNullOrEmpty()

val iconContent: @Composable (() -> Unit)? = if (!action.icon.isNullOrBlank()) {
{
MaterialIcon(
name = action.icon!!,
contentDescription = action.label ?: action.id,
tint = textColor ?: MaterialTheme.colorScheme.onSurface
)
}
} else null

if (hasChildren) {
// Render an unclickable header, then recursively append its children.
DropdownMenuItem(
text = { ActionTextContent(action, textColor) },
onClick = {},
enabled = false,
leadingIcon = iconContent
)
BuildMenuElements(action.children!!, textColor, onDismiss, handleActionClick)
} else {
DropdownMenuItem(
text = { ActionTextContent(action, textColor) },
onClick = {
onDismiss()
handleActionClick(action)
},
leadingIcon = iconContent
)
}
}
}
}

/**
* Text renderer component supporting trailing subtitles natively in the DropdownMenuItem.
*/
@Composable
private fun ActionTextContent(action: TopBarActionData, textColor: Color?) {
Column {
Text(action.label ?: action.id ?: "")
action.subtitle?.let { subtitle ->
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = (textColor ?: MaterialTheme.colorScheme.onSurface).copy(alpha = 0.7f)
)
}
}
}

/**
* Check if a URL is external (not a relative path or localhost)
*/
private fun isExternalUrl(url: String): Boolean {
private fun isExternalUrl(url: String): Boolean {
return (url.startsWith("http://") || url.startsWith("https://"))
&& !url.contains("127.0.0.1")
&& !url.contains("localhost")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,6 @@ data class SideNavData(

/**
* Side nav child component - can be an item, group, or divider
* For items: type="side_nav_item", data contains SideNavItem
* For groups: type="side_nav_group", data contains SideNavGroup
* For dividers: type="horizontal_divider", data is null/empty
*/
data class SideNavChild(
val type: String,
Expand Down Expand Up @@ -143,22 +140,27 @@ data class TopBarData(
)

/**
* Top bar action as a component (wraps TopBarAction data)
* Top bar action as a component (wraps TopBarActionData)
*/
data class TopBarActionComponent(
val type: String,
val data: TopBarAction
val data: TopBarActionData
)

/**
* Top bar action item data
* Universal data class to support both standard Actions and nested Sections.
* Properties not relevant to a specific component type (like 'title' for actions) simply decode as null.
*/
data class TopBarAction(
val id: String,
val icon: String,
data class TopBarActionData(
val id: String? = null,
val icon: String? = null,
val label: String? = null,
val subtitle: String? = null,
val title: String? = null, // Used primarily by sections
val url: String? = null,
val event: String? = null
val event: String? = null,
val role: String? = null, // Parsed but safely ignored by UI as requested
val children: List<TopBarActionComponent>? = null
)

/**
Expand All @@ -169,13 +171,13 @@ data class FabData(
val icon: String,
val url: String? = null,
val event: String? = null,
val size: String? = "regular", // "small", "regular", "large", "extended"
val position: String? = "end", // "end", "center", "start"
val size: String? = "regular",
val position: String? = "end",
@SerializedName("bottom_offset")
val bottomOffset: Int? = null, // Offset from bottom in dp
val elevation: Int? = null, // Elevation in dp
val bottomOffset: Int? = null,
val elevation: Int? = null,
@SerializedName("corner_radius")
val cornerRadius: Int? = null, // Corner radius in dp (default: circular)
val cornerRadius: Int? = null,
@SerializedName("container_color")
val containerColor: String? = null,
@SerializedName("content_color")
Expand All @@ -200,21 +202,16 @@ object NativeUIParser {
return try {
Log.d("NativeUIParser", "parseFromObject called with type: ${obj.javaClass.name}")

// Convert the object to JsonElement
// Handle org.json types (from bridge) separately from Gson types
val jsonTree = when (obj) {
is JSONArray -> {
// Convert org.json.JSONArray to Gson JsonElement
Log.d("NativeUIParser", "Converting JSONArray: ${obj.toString()}")
JsonParser.parseString(obj.toString())
}
is JSONObject -> {
// Convert org.json.JSONObject to Gson JsonElement
Log.d("NativeUIParser", "Converting JSONObject: ${obj.toString()}")
JsonParser.parseString(obj.toString())
}
else -> {
// For other types, use Gson's toJsonTree
Log.d("NativeUIParser", "Using toJsonTree for: ${obj.javaClass.name}")
gson.toJsonTree(obj)
}
Expand Down Expand Up @@ -288,4 +285,4 @@ object NativeUIParser {
null
}
}
}
}
Loading