diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeTopBar.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeTopBar.kt index de18776b..e07291c1 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeTopBar.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeTopBar.kt @@ -4,7 +4,9 @@ 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.* @@ -12,6 +14,7 @@ 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" @@ -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( @@ -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", @@ -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 + ) } } }, @@ -171,10 +177,96 @@ fun NativeTopBar( ) } +/** + * Recursively builds dropdown menu elements handling Sections and Action groupings. + */ +@Composable +private fun ColumnScope.BuildMenuElements( + components: List, + 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") diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIModels.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIModels.kt index 3437c43f..ec77cd77 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIModels.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIModels.kt @@ -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, @@ -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? = null ) /** @@ -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") @@ -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) } @@ -288,4 +285,4 @@ object NativeUIParser { null } } -} +} \ No newline at end of file diff --git a/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift b/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift index 84be2408..e3b10968 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift @@ -85,23 +85,54 @@ struct NativeTopBar: UIViewRepresentable { // Update right bar buttons (actions) if let actions = topBarData.children, !actions.isEmpty { var barButtonItems: [UIBarButtonItem] = [] + context.coordinator.actionUrls.removeAll() for action in actions { - let image = !action.data.icon.isEmpty ? UIImage(systemName: getIconForName(action.data.icon)) : nil + guard case let .action(actionData) = action.data else { + continue + } + + let image = !actionData.icon.isEmpty ? UIImage(systemName: getIconForName(actionData.icon)) : nil + let childActions = actionData.children ?? [] + + if !childActions.isEmpty { + if #available(iOS 26.0, *) { + let menuElements = buildMenuElements(from: childActions, context: context) + + if !menuElements.isEmpty { + let menu = UIMenu(title: "", children: menuElements) + let button = UIBarButtonItem( + title: actionData.label, + image: image, + primaryAction: nil, + menu: menu + ) + button.accessibilityLabel = actionData.label + button.accessibilityIdentifier = actionData.id + barButtonItems.append(button) + } + + continue + } + } + + guard let actionUrl = actionData.url, !actionUrl.isEmpty else { + continue + } // Create button with both image and title when available let button = UIBarButtonItem( - title: action.data.label, + title: actionData.label, image: image, target: context.coordinator, action: #selector(Coordinator.actionTapped(_:)) ) - button.accessibilityLabel = action.data.label - button.accessibilityIdentifier = action.data.id + button.accessibilityLabel = actionData.label + button.accessibilityIdentifier = actionData.id // Store the URL in the button's tag by storing it in coordinator - context.coordinator.actionUrls[action.data.id] = action.data.url + context.coordinator.actionUrls[actionData.id] = actionUrl barButtonItems.append(button) } @@ -171,5 +202,60 @@ struct NativeTopBar: UIViewRepresentable { // Navigate to the URL using the proper navigation callback onNavigate(url) } + + func navigate(to url: String) { + onNavigate(url) + } } } + +private func buildMenuElements( + from components: [TopBarActionComponent], + context: NativeTopBar.Context +) -> [UIMenuElement] { + var elements: [UIMenuElement] = [] + + for component in components { + switch component.data { + case .section(let section): + let sectionElements = buildMenuElements(from: section.children ?? [], context: context) + if !sectionElements.isEmpty { + let sectionMenu = UIMenu(title: section.title, options: .displayInline, children: sectionElements) + elements.append(sectionMenu) + } + case .action(let action): + guard let url = action.url, !url.isEmpty else { + continue + } + + let image = !action.icon.isEmpty ? UIImage(systemName: getIconForName(action.icon)) : nil + var attributes: UIMenuElement.Attributes = [] + if action.role == "destructive" { + attributes.insert(.destructive) + } + + let menuAction = UIAction( + title: action.label, + image: image, + identifier: nil, + discoverabilityTitle: nil, + attributes: attributes, + state: .off + ) { _ in + context.coordinator.navigate(to: url) + } + + if #available(iOS 15.0, *), + let subtitle = action.subtitle, + !subtitle.isEmpty { + menuAction.subtitle = subtitle + } + + elements.append(menuAction) + default: + continue + } + } + + return elements +} \ No newline at end of file diff --git a/resources/xcode/NativePHP/NativeUI/NativeUIModels.swift b/resources/xcode/NativePHP/NativeUI/NativeUIModels.swift index e9b22c7d..007d9e41 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeUIModels.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeUIModels.swift @@ -211,14 +211,65 @@ struct TopBarData: Codable, Equatable { struct TopBarActionComponent: Codable, Equatable { let type: String - let data: TopBarAction + let data: TopBarActionData? + + enum CodingKeys: String, CodingKey { + case type, data + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decode(String.self, forKey: .type) + + switch type { + case "top_bar_section": + if let section = try? container.decode(TopBarSection.self, forKey: .data) { + data = .section(section) + } else { + data = nil + } + default: + if let action = try? container.decode(TopBarAction.self, forKey: .data) { + data = .action(action) + } else { + data = nil + } + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + + switch data { + case .action(let action): + try container.encode(action, forKey: .data) + case .section(let section): + try container.encode(section, forKey: .data) + case .none: + try container.encodeNil(forKey: .data) + } + } } struct TopBarAction: Codable, Equatable { let id: String let label: String - let url: String + let subtitle: String? + let url: String? let icon: String + let role: String? + let children: [TopBarActionComponent]? +} + +struct TopBarSection: Codable, Equatable { + let title: String + let children: [TopBarActionComponent]? +} + +enum TopBarActionData: Equatable { + case action(TopBarAction) + case section(TopBarSection) } // MARK: - NativeUI Parser diff --git a/src/Commands/PluginValidateCommand.php b/src/Commands/PluginValidateCommand.php index 66c41b14..273cb597 100644 --- a/src/Commands/PluginValidateCommand.php +++ b/src/Commands/PluginValidateCommand.php @@ -320,6 +320,7 @@ protected function findSwiftFile(string $basePath, string $className): ?string } $flatPath = $basePath.'/resources/ios/'.$class.'.swift'; + return $this->files->exists($flatPath) ? $flatPath : null; } diff --git a/src/Edge/Components/Navigation/TopBarAction.php b/src/Edge/Components/Navigation/TopBarAction.php index d342c8b3..4979038f 100644 --- a/src/Edge/Components/Navigation/TopBarAction.php +++ b/src/Edge/Components/Navigation/TopBarAction.php @@ -12,8 +12,10 @@ public function __construct( public ?string $id = null, public ?string $icon = null, public ?string $label = null, + public ?string $subtitle = null, public ?string $url = null, public ?string $event = null, + public ?string $role = null, ) {} protected function requiredProps(): array @@ -27,8 +29,10 @@ protected function toNativeProps(): array 'id' => $this->id, 'icon' => $this->icon, 'label' => $this->label, + 'subtitle' => $this->subtitle, 'url' => $this->url, 'event' => $this->event, + 'role' => $this->role, ]; } } diff --git a/src/Edge/Components/Navigation/TopBarGroup.php b/src/Edge/Components/Navigation/TopBarGroup.php new file mode 100644 index 00000000..6fe1fe42 --- /dev/null +++ b/src/Edge/Components/Navigation/TopBarGroup.php @@ -0,0 +1,32 @@ + $this->id, + 'icon' => $this->icon, + 'label' => $this->label, + ], fn ($value) => $value !== null && $value !== ''); + } +} diff --git a/src/Edge/Components/Navigation/TopBarSection.php b/src/Edge/Components/Navigation/TopBarSection.php new file mode 100644 index 00000000..1de35c3e --- /dev/null +++ b/src/Edge/Components/Navigation/TopBarSection.php @@ -0,0 +1,28 @@ + $this->title, + ]; + } +}