From ff2f619c8293c296732be6eedd6599f5dd679745 Mon Sep 17 00:00:00 2001 From: Adel Ali <3adeling@gmail.com> Date: Mon, 9 Mar 2026 05:20:07 +0300 Subject: [PATCH 1/4] add RTL support --- resources/xcode/NativePHP/AppDelegate.swift | 49 ++- resources/xcode/NativePHP/Info.plist | 5 + resources/xcode/NativePHP/NativePHPApp.swift | 50 ++- .../NativePHP/NativeUI/NativeBottomNav.swift | 12 +- .../NativePHP/NativeUI/NativeSideNav.swift | 416 +++++++++--------- .../NativePHP/NativeUI/NativeTopBar.swift | 26 +- 6 files changed, 294 insertions(+), 264 deletions(-) diff --git a/resources/xcode/NativePHP/AppDelegate.swift b/resources/xcode/NativePHP/AppDelegate.swift index 0295973..c96bc78 100644 --- a/resources/xcode/NativePHP/AppDelegate.swift +++ b/resources/xcode/NativePHP/AppDelegate.swift @@ -33,9 +33,26 @@ class AppDelegate: NSObject, UIApplicationDelegate { // Called when the app is launched func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + + let locale = Locale.current.language.languageCode?.identifier ?? "en" + let isRTL = Locale.characterDirection(forLanguage: locale) == .rightToLeft + + let direction: UISemanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight + + // RTL: Force right-to-left layout direction for all UIKit views app-wide. + // Must be called before any window or view is created so appearance proxies + // take effect on every UIKit component (UITabBar, UINavigationBar, etc.). + UIView.appearance().semanticContentAttribute = direction + UINavigationBar.appearance().semanticContentAttribute = direction + UITabBar.appearance().semanticContentAttribute = direction + UITableView.appearance().semanticContentAttribute = direction + UICollectionView.appearance().semanticContentAttribute = direction + UITextField.appearance().textAlignment = .natural + UILabel.appearance().textAlignment = .natural + // Check if the app was launched from a URL (custom scheme) if let url = launchOptions?[UIApplication.LaunchOptionsKey.url] as? URL { DebugLogger.shared.log("📱 AppDelegate: Cold start with custom scheme URL: \(url)") @@ -45,9 +62,9 @@ class AppDelegate: NSObject, UIApplicationDelegate { // Check if the app was launched from a Universal Link if let userActivityDictionary = launchOptions?[UIApplication.LaunchOptionsKey.userActivityDictionary] as? [String: Any], - let userActivity = userActivityDictionary["UIApplicationLaunchOptionsUserActivityKey"] as? NSUserActivity, - userActivity.activityType == NSUserActivityTypeBrowsingWeb, - let url = userActivity.webpageURL { + let userActivity = userActivityDictionary["UIApplicationLaunchOptionsUserActivityKey"] as? NSUserActivity, + userActivity.activityType == NSUserActivityTypeBrowsingWeb, + let url = userActivity.webpageURL { DebugLogger.shared.log("📱 AppDelegate: Cold start with Universal Link: \(url)") // Pass the URL to the DeepLinkRouter DeepLinkRouter.shared.handle(url: url) @@ -58,13 +75,13 @@ class AppDelegate: NSObject, UIApplicationDelegate { // Called for Universal Links func application( - _ application: UIApplication, - continue userActivity: NSUserActivity, - restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void ) -> Bool { // Check if this is a Universal Link if userActivity.activityType == NSUserActivityTypeBrowsingWeb, - let url = userActivity.webpageURL { + let url = userActivity.webpageURL { // Pass the URL to the DeepLinkRouter DeepLinkRouter.shared.handle(url: url) return true @@ -76,8 +93,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { // MARK: - Push Notification Token Handling (forwards to plugins via NotificationCenter) func application( - _ application: UIApplication, - didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { NotificationCenter.default.post( name: .didRegisterForRemoteNotifications, @@ -87,8 +104,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { } func application( - _ application: UIApplication, - didFailToRegisterForRemoteNotificationsWithError error: Error + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error ) { NotificationCenter.default.post( name: .didFailToRegisterForRemoteNotifications, @@ -99,9 +116,9 @@ class AppDelegate: NSObject, UIApplicationDelegate { // Handle deeplinks func application( - _ app: UIApplication, - open url: URL, - options: [UIApplication.OpenURLOptionsKey: Any] = [:] + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { // Pass the URL to the DeepLinkRouter DeepLinkRouter.shared.handle(url: url) diff --git a/resources/xcode/NativePHP/Info.plist b/resources/xcode/NativePHP/Info.plist index 7d6768e..6ac6450 100644 --- a/resources/xcode/NativePHP/Info.plist +++ b/resources/xcode/NativePHP/Info.plist @@ -15,6 +15,11 @@ + CFBundleLocalizations + + ar + en + NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/resources/xcode/NativePHP/NativePHPApp.swift b/resources/xcode/NativePHP/NativePHPApp.swift index f7bc114..8430a0b 100644 --- a/resources/xcode/NativePHP/NativePHPApp.swift +++ b/resources/xcode/NativePHP/NativePHPApp.swift @@ -81,22 +81,30 @@ struct NativePHPApp: App { // It renders underneath the splash until WebView finishes loading if appState.isReadyToLoad { ContentView() + .environment(\.layoutDirection, .rightToLeft) + .environment(\.locale, Locale(identifier: "ar")) } // Splash overlays until WebView finishes loading (Phase 3) if !appState.isInitialized { SplashView() - .transition(.opacity) - .onAppear { - // Phase 1: Start deferred initialization on a background thread - // This runs AFTER the splash view is visible, avoiding watchdog timeout - DispatchQueue.global(qos: .userInitiated).async { - performDeferredInitialization() - } + .transition(.opacity) + .onAppear { + // Phase 1: Start deferred initialization on a background thread + // This runs AFTER the splash view is visible, avoiding watchdog timeout + DispatchQueue.global(qos: .userInitiated).async { + performDeferredInitialization() } + } } } .animation(.easeInOut(duration: 0.3), value: appState.isInitialized) + // RTL: Apply right-to-left layout direction to the entire SwiftUI view hierarchy. + // This covers NativeSideNav, SideNavItemView, SideNavGroupView, and all + // other SwiftUI-based NativeUI components. UIKit components (UITabBar, + // UINavigationBar) are handled separately via UIView.appearance() in AppDelegate. + .environment(\.layoutDirection, .rightToLeft) + .environment(\.locale, Locale(identifier: "ar")) .onOpenURL { url in // Only handle if not already handled by AppDelegate during cold start if !DeepLinkRouter.shared.hasPendingURL() { @@ -142,7 +150,7 @@ struct NativePHPApp: App { let envPath = URL(fileURLWithPath: appPath).appendingPathComponent(".env") guard FileManager.default.fileExists(atPath: envPath.path), - let envContent = try? String(contentsOf: envPath, encoding: .utf8) else { + let envContent = try? String(contentsOf: envPath, encoding: .utf8) else { DebugLogger.shared.log("⚙️ No .env file found, using default start URL") return "/" } @@ -150,11 +158,11 @@ struct NativePHPApp: App { // Use regex to find NATIVEPHP_START_URL value let pattern = #"NATIVEPHP_START_URL\s*=\s*([^\r\n]+)"# if let regex = try? NSRegularExpression(pattern: pattern), - let match = regex.firstMatch(in: envContent, range: NSRange(envContent.startIndex..., in: envContent)), - let valueRange = Range(match.range(at: 1), in: envContent) { + let match = regex.firstMatch(in: envContent, range: NSRange(envContent.startIndex..., in: envContent)), + let valueRange = Range(match.range(at: 1), in: envContent) { var value = String(envContent[valueRange]) - .trimmingCharacters(in: .whitespaces) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + .trimmingCharacters(in: .whitespaces) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) if !value.isEmpty { // Ensure path starts with / @@ -174,9 +182,9 @@ struct NativePHPApp: App { let caPath = Bundle.main.path(forResource: "cacert", ofType: "pem") ?? "Path not found" let phpIni = """ - curl.cainfo="\(caPath)" - openssl.cafile="\(caPath)" - """ + curl.cainfo="\(caPath)" + openssl.cafile="\(caPath)" + """ let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let path = supportDir.appendingPathComponent("php.ini") @@ -196,7 +204,7 @@ struct NativePHPApp: App { let fileManager = FileManager.default let databaseFileURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - .appendingPathComponent("database/database.sqlite") + .appendingPathComponent("database/database.sqlite") if !fileManager.fileExists(atPath: databaseFileURL.path) { // Create an empty SQLite file @@ -286,12 +294,12 @@ struct NativePHPApp: App { for (header, value) in request.headers { let formattedKey = "HTTP_" + header - .replacingOccurrences(of: "-", with: "_") - .uppercased() + .replacingOccurrences(of: "-", with: "_") + .uppercased() // Convert Swift strings to C strings guard let cKey = formattedKey.cString(using: .utf8), - let cValue = value.cString(using: .utf8) else { + let cValue = value.cString(using: .utf8) else { print("Failed to convert \(header) or its value to C string.") continue } @@ -375,8 +383,8 @@ struct NativePHPApp: App { let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecSuccess, - let data = result as? Data, - let existingKey = String(data: data, encoding: .utf8) { + let data = result as? Data, + let existingKey = String(data: data, encoding: .utf8) { DebugLogger.shared.log("🔑 Retrieved existing APP_KEY from Keychain") return existingKey } diff --git a/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift b/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift index c1006f7..bbfd5f4 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift @@ -27,6 +27,10 @@ struct NativeUITabBar: UIViewRepresentable { tabBar.scrollEdgeAppearance = appearance } + // RTL: Force right-to-left layout so tab items are ordered right-to-left, + // matching the natural reading direction for Arabic apps. + tabBar.semanticContentAttribute = .forceRightToLeft + // Apply custom active color (tint color for selected items) if let activeColorHex = activeColor, let color = UIColor(hex: activeColorHex) { tabBar.tintColor = color @@ -241,7 +245,7 @@ struct NativeUITabBar: UIViewRepresentable { } guard let index = tabBar.items?.firstIndex(of: item), - index < items.count else { return } + index < items.count else { return } let selectedItem = items[index] @@ -270,7 +274,7 @@ struct NativeBottomNavigation: View { var body: some View { if let bottomNavData = uiState.bottomNavData, - let items = bottomNavData.children, !items.isEmpty { + let items = bottomNavData.children, !items.isEmpty { if #available(iOS 26.0, *) { // iOS 26+: Tab bar with Liquid Glass effect @@ -346,8 +350,8 @@ struct NativeBottomNavigation: View { /// Load URL for a specific tab by finding the item and passing its raw URL private func loadTabURL(tabId: String) { guard let bottomNavData = uiState.bottomNavData, - let items = bottomNavData.children, - let item = items.first(where: { $0.data.id == tabId }) else { + let items = bottomNavData.children, + let item = items.first(where: { $0.data.id == tabId }) else { print("❌ No item found for tab: \(tabId)") return } diff --git a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift index 7538acc..9bda7b1 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift @@ -1,18 +1,43 @@ import SwiftUI -/// Dynamic Side Navigation using slide-in drawer with swipe gesture +/// Bidirectional Side Navigation — supports both LTR and RTL layouts. +/// +/// Reads `layoutDirection` from the SwiftUI environment and adapts automatically: +/// - LTR: drawer slides in from the LEFT edge +/// - RTL: drawer slides in from the RIGHT edge +/// +/// All offset and gesture math is normalised using a `dir` multiplier: +/// dir = -1 (LTR) → negative x hides drawer off-screen left +/// dir = +1 (RTL) → positive x hides drawer off-screen right +/// +/// slideOffset = 0 → drawer fully visible at its edge +/// slideOffset = drawerWidth * dir → drawer fully hidden off-screen struct NativeSideNavigation: View { @ObservedObject var uiState = NativeUIState.shared + private var isRTL: Bool { UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft } @State private var expandedGroups: Set = [] @State private var dragOffset: CGFloat = 0 let content: Content let onNavigate: (String) -> Void - // Drawer dimensions - private let drawerWidthRatio: CGFloat = 0.85 // 85% of screen width + private let drawerWidthRatio: CGFloat = 0.85 private let edgeSwipeThreshold: CGFloat = 30 + /// +1 for RTL (drawer on right), -1 for LTR (drawer on left). + /// Multiplying any offset or velocity by this normalises direction math. + private var dir: CGFloat { isRTL ? 1 : -1 } + + /// The physical edge the drawer appears on. + private var drawerEdge: Edge { isRTL ? .trailing : .leading } + + /// Safe area inset on the drawer's side. + private func safeInset(_ geometry: GeometryProxy) -> CGFloat { + isRTL + ? geometry.safeAreaInsets.trailing + : geometry.safeAreaInsets.leading + } + init(onNavigate: @escaping (String) -> Void, @ViewBuilder content: () -> Content) { self.onNavigate = onNavigate self.content = content() @@ -20,202 +45,185 @@ struct NativeSideNavigation: View { var body: some View { GeometryReader { geometry in - // Use smaller drawer width in landscape mode let isLandscape = geometry.size.width > geometry.size.height - let drawerWidthMultiplier = isLandscape ? 0.4 : drawerWidthRatio - let drawerWidth = geometry.size.width * drawerWidthMultiplier - - // Computed drawer X offset based on state and drag - // Add extra offset to account for safe area and ensure complete hiding + let drawerWidth = geometry.size.width * (isLandscape ? 0.4 : drawerWidthRatio) + let safeArea = safeInset(geometry) + + // Hidden offset: moves drawer fully off-screen on its side. + // dir * (drawerWidth + safeArea + 10) + // LTR → negative x → off-screen LEFT + // RTL → positive x → off-screen RIGHT + let hiddenOffset = dir * (drawerWidth + safeArea + 10) + + // Current drawer x position: + // open: 0 (drawer at edge, fully visible) + // closed: hiddenOffset (drawer off-screen) let drawerXOffset: CGFloat = { - let safeAreaOffset = geometry.safeAreaInsets.leading - let baseOffset = uiState.shouldPresentSidebar ? 0 : -(drawerWidth + safeAreaOffset + 10) - return baseOffset + dragOffset + let base: CGFloat = uiState.shouldPresentSidebar ? 0 : hiddenOffset + return base + dragOffset }() - // Overlay opacity based on drawer position + // Overlay opacity: 0 when hidden, 0.5 when fully open let overlayOpacity: Double = { let progress = 1 - abs(drawerXOffset) / drawerWidth return Double(max(0, min(0.5, progress * 0.5))) }() - // Edge swipe gesture to open drawer + // Swipe inward from the drawer's edge to open. + // Inward direction = opposite of dir: + // LTR: swipe right (+) from left edge + // RTL: swipe left (-) from right edge let edgeSwipeGesture = DragGesture(minimumDistance: 10) - .onChanged { value in - // Only open if swiping right from the very left edge (0 to threshold) - if value.translation.width > 0 && value.startLocation.x < edgeSwipeThreshold { - let safeAreaOffset = geometry.safeAreaInsets.leading - let maxOffset = drawerWidth + safeAreaOffset + 10 - // Clamp to not go beyond fully open (0), but don't stop the gesture - dragOffset = min(value.translation.width, maxOffset) - } - } - .onEnded { value in - // Open if swiped far enough or with enough velocity - let threshold = drawerWidth * 0.3 - let velocity = value.predictedEndTranslation.width - value.translation.width - - if value.translation.width > threshold || velocity > 300 { - withAnimation(.easeOut(duration: 0.25)) { - uiState.openSidebar() - dragOffset = 0 - } - } else { - withAnimation(.easeOut(duration: 0.25)) { - dragOffset = 0 - } - } - } + .onChanged { value in + // Must start from the correct physical edge + let atEdge = isRTL + ? value.startLocation.x > geometry.size.width - edgeSwipeThreshold + : value.startLocation.x < edgeSwipeThreshold - // Drawer drag gesture to close - let drawerDragGesture = DragGesture() - .onChanged { value in - // Only allow dragging left to close - if value.translation.width < 0 { - let safeAreaOffset = geometry.safeAreaInsets.leading - let maxDrag = drawerWidth + safeAreaOffset + 10 - // Clamp to not go beyond fully closed, but don't stop the gesture - dragOffset = max(value.translation.width, -maxDrag) - } + guard atEdge else { return } + + // Normalised translation: negative = inward (opening) + let norm = dir * value.translation.width + guard norm < 0 else { return } + + // dragOffset mirrors the normalised translation back to screen coords + // and clamps so drawer can't go past fully open + dragOffset = max(norm, -abs(hiddenOffset)) * dir + } + .onEnded { value in + let normTranslation = dir * value.translation.width + let normVelocity = dir * (value.predictedEndTranslation.width - value.translation.width) + + if abs(normTranslation) > drawerWidth * 0.3 || normVelocity < -300 { + open() + } else { + withAnimation(.easeOut(duration: 0.25)) { dragOffset = 0 } } - .onEnded { value in - // Close if dragged far enough or with enough velocity - let threshold = drawerWidth * 0.3 - let velocity = value.predictedEndTranslation.width - value.translation.width - - if abs(value.translation.width) > threshold || velocity < -300 { - withAnimation(.easeOut(duration: 0.25)) { - uiState.closeSidebar() - dragOffset = 0 - } - } else { - withAnimation(.easeOut(duration: 0.25)) { - dragOffset = 0 - } - } + } + + // Swipe outward on drawer or overlay to close. + // Outward direction = same as dir: + // LTR: swipe left (-) → close + // RTL: swipe right (+) → close + let closeDragGesture = DragGesture() + .onChanged { value in + // Normalised translation: positive = outward (closing) + let norm = dir * value.translation.width + guard norm > 0 else { return } + dragOffset = min(norm, abs(hiddenOffset)) * dir + } + .onEnded { value in + let normTranslation = dir * value.translation.width + let normVelocity = dir * (value.predictedEndTranslation.width - value.translation.width) + + if normTranslation > drawerWidth * 0.3 || normVelocity > 300 { + close() + } else { + withAnimation(.easeOut(duration: 0.25)) { dragOffset = 0 } } + } - ZStack(alignment: .leading) { - // Main content + ZStack(alignment: isRTL ? .trailing : .leading) { + // ── Main content ────────────────────────────────────────────── content - .zIndex(0) - .disabled(uiState.shouldPresentSidebar) + .zIndex(0) + .disabled(uiState.shouldPresentSidebar) - // Dimmed overlay when drawer is open or being dragged + // ── Dim overlay ─────────────────────────────────────────────── if uiState.shouldPresentSidebar || dragOffset != 0 { Color.black - .opacity(overlayOpacity) - .ignoresSafeArea() - .zIndex(1) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.3)) { - uiState.closeSidebar() - dragOffset = 0 - } - } - .gesture( - DragGesture() - .onChanged { value in - // Allow closing by swiping left on overlay - if value.translation.width < 0 { - let safeAreaOffset = geometry.safeAreaInsets.leading - let maxDrag = drawerWidth + safeAreaOffset + 10 - // Clamp to not go beyond fully closed, but don't stop the gesture - dragOffset = max(value.translation.width, -maxDrag) - } - } - .onEnded { value in - let threshold = drawerWidth * 0.3 - let velocity = value.predictedEndTranslation.width - value.translation.width - - if abs(value.translation.width) > threshold || velocity < -300 { - withAnimation(.easeOut(duration: 0.25)) { - uiState.closeSidebar() - dragOffset = 0 - } - } else { - withAnimation(.easeOut(duration: 0.25)) { - dragOffset = 0 - } - } - } - ) - .transition(.opacity) + .opacity(overlayOpacity) + .ignoresSafeArea() + .zIndex(1) + .onTapGesture { close() } + .gesture(closeDragGesture) + .transition(.opacity) } - // Side drawer + // ── Side drawer ─────────────────────────────────────────────── if uiState.hasSideNav() { drawerContent - .frame(width: drawerWidth) - .background(Color(.systemBackground)) - .offset(x: drawerXOffset) - .zIndex(2) - .gesture(drawerDragGesture) - .onAppear { - print("📱 NativeSideNavigation: Side nav available") - // Initialize expanded groups from data - if let children = uiState.sideNavData?.children { - for child in children where child.type == "side_nav_group" { - if case .group(let group) = child.data, - group.expanded == true { - expandedGroups.insert(group.heading) - } + .frame(width: drawerWidth) + .background(Color(.systemBackground)) + .offset(x: drawerXOffset) + .zIndex(2) + .gesture(closeDragGesture) + .onAppear { + let side = isRTL ? "RIGHT (RTL)" : "LEFT (LTR)" + print("📱 NativeSideNavigation: drawer on \(side) edge") + if let children = uiState.sideNavData?.children { + for child in children where child.type == "side_nav_group" { + if case .group(let group) = child.data, + group.expanded == true { + expandedGroups.insert(group.heading) } } } + } } - // Invisible edge detector for swipe-to-open + // ── Invisible edge detector for swipe-to-open ──────────────── if uiState.hasSideNav() && !uiState.shouldPresentSidebar { Color.clear - .frame(width: edgeSwipeThreshold) - .contentShape(Rectangle()) - .gesture(edgeSwipeGesture) - .ignoresSafeArea(edges: .leading) - .zIndex(3) + .frame(width: edgeSwipeThreshold) + .contentShape(Rectangle()) + .gesture(edgeSwipeGesture) + .ignoresSafeArea(edges: isRTL ? .trailing : .leading) + .zIndex(3) } } .onChange(of: uiState.shouldPresentSidebar) { _, newValue in if newValue { - withAnimation(.easeInOut(duration: 0.3)) { - dragOffset = 0 - } + withAnimation(.easeInOut(duration: 0.3)) { dragOffset = 0 } } } } } - // Drawer content + // MARK: - Actions + + private func open() { + withAnimation(.easeOut(duration: 0.25)) { + uiState.openSidebar() + dragOffset = 0 + } + } + + private func close() { + withAnimation(.easeOut(duration: 0.25)) { + uiState.closeSidebar() + dragOffset = 0 + } + } + + // MARK: - Drawer Content + @ViewBuilder private var drawerContent: some View { if let sideNavData = uiState.sideNavData, - let children = sideNavData.children { - // Separate pinned headers from scrollable content + let children = sideNavData.children { let pinnedHeaders = children.filter { child in if child.type == "side_nav_header", - case .header(let header) = child.data { + case .header(let header) = child.data { return header.pinned == true } return false } let scrollableChildren = children.filter { child in if child.type == "side_nav_header", - case .header(let header) = child.data { + case .header(let header) = child.data { return header.pinned != true } return true } VStack(spacing: 0) { - // Pinned headers at the top (non-scrollable) - ForEach(Array(pinnedHeaders.enumerated()), id: \.offset) { index, child in + ForEach(Array(pinnedHeaders.enumerated()), id: \.offset) { _, child in sideNavChild(child: child) } - - // Scrollable content ScrollView { VStack(alignment: .leading, spacing: 0) { - ForEach(Array(scrollableChildren.enumerated()), id: \.offset) { index, child in + ForEach(Array(scrollableChildren.enumerated()), id: \.offset) { _, child in sideNavChild(child: child) } } @@ -232,7 +240,6 @@ struct NativeSideNavigation: View { if case .header(let header) = child.data { SideNavHeaderView(header: header) } - case "side_nav_item": if case .item(let item) = child.data { SideNavItemView( @@ -240,13 +247,10 @@ struct NativeSideNavigation: View { labelVisibility: uiState.sideNavData?.labelVisibility, onNavigate: { url in onNavigate(url) - withAnimation(.easeInOut(duration: 0.3)) { - uiState.closeSidebar() - } + withAnimation(.easeInOut(duration: 0.3)) { uiState.closeSidebar() } } ) } - case "side_nav_group": if case .group(let group) = child.data { SideNavGroupView( @@ -264,50 +268,39 @@ struct NativeSideNavigation: View { labelVisibility: uiState.sideNavData?.labelVisibility, onNavigate: { url in onNavigate(url) - withAnimation(.easeInOut(duration: 0.3)) { - uiState.closeSidebar() - } + withAnimation(.easeInOut(duration: 0.3)) { uiState.closeSidebar() } } ) } - case "horizontal_divider": - Divider() - .padding(.vertical, 8) - + Divider().padding(.vertical, 8) default: EmptyView() } } } -/// Side nav header view +// MARK: - SideNavHeaderView + struct SideNavHeaderView: View { let header: SideNavHeader var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 16) { - // Icon if let iconName = header.icon { Image(systemName: getIconForName(iconName)) - .font(.system(size: 40)) - .foregroundColor(.accentColor) + .font(.system(size: 40)) + .foregroundColor(.accentColor) } - - // Title and subtitle VStack(alignment: .leading, spacing: 4) { if let title = header.title { - Text(title) - .font(.headline) + Text(title).font(.headline) } if let subtitle = header.subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundColor(.secondary) + Text(subtitle).font(.subheadline).foregroundColor(.secondary) } } - Spacer() } .padding(16) @@ -320,22 +313,20 @@ struct SideNavHeaderView: View { private func parseBackgroundColor(_ colorString: String?) -> Color { guard let colorString = colorString else { return Color(.systemGray6) } - // Simple hex color parsing let hex = colorString.replacingOccurrences(of: "#", with: "") guard hex.count == 6 || hex.count == 8 else { return Color(.systemGray6) } - var rgb: UInt64 = 0 Scanner(string: hex).scanHexInt64(&rgb) - - let r = Double((rgb >> 16) & 0xFF) / 255.0 - let g = Double((rgb >> 8) & 0xFF) / 255.0 - let b = Double(rgb & 0xFF) / 255.0 - - return Color(red: r, green: g, blue: b) + return Color( + red: Double((rgb >> 16) & 0xFF) / 255.0, + green: Double((rgb >> 8) & 0xFF) / 255.0, + blue: Double(rgb & 0xFF) / 255.0 + ) } } -/// Single side nav item +// MARK: - SideNavItemView + struct SideNavItemView: View { let item: SideNavItem let labelVisibility: String? @@ -349,27 +340,26 @@ struct SideNavItemView: View { }) { HStack(spacing: 16) { Image(systemName: getIconForName(item.icon)) - .font(.system(size: 20)) - .foregroundColor(item.active == true ? .accentColor : .primary) - .frame(width: 24) + .font(.system(size: 20)) + .foregroundColor(item.active == true ? .accentColor : .primary) + .frame(width: 24) if shouldShowLabel() { Text(item.label) - .foregroundColor(item.active == true ? .accentColor : .primary) + .foregroundColor(item.active == true ? .accentColor : .primary) } Spacer() - // Badge if let badge = item.badge { Text(badge) - .font(.caption2) - .fontWeight(.semibold) - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(parseBadgeColor(item.badgeColor)) - .cornerRadius(12) + .font(.caption2) + .fontWeight(.semibold) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(parseBadgeColor(item.badgeColor)) + .cornerRadius(12) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -385,18 +375,15 @@ struct SideNavItemView: View { private func shouldShowLabel() -> Bool { switch labelVisibility { case "unlabeled": return false - case "selected": return item.active == true - default: return true + case "selected": return item.active == true + default: return true } } private func handleNavigation() { - // Check if should open in browser if item.openInBrowser == true || isExternalUrl(item.url) { print("🌐 Opening external URL: \(item.url)") - if let url = URL(string: item.url) { - UIApplication.shared.open(url) - } + if let url = URL(string: item.url) { UIApplication.shared.open(url) } } else { print("📱 Opening internal URL: \(item.url)") onNavigate(item.url) @@ -404,27 +391,28 @@ struct SideNavItemView: View { } private func isExternalUrl(_ url: String) -> Bool { - return (url.hasPrefix("http://") || url.hasPrefix("https://")) - && !url.contains("127.0.0.1") - && !url.contains("localhost") + (url.hasPrefix("http://") || url.hasPrefix("https://")) + && !url.contains("127.0.0.1") + && !url.contains("localhost") } private func parseBadgeColor(_ colorString: String?) -> Color { switch colorString?.lowercased() { - case "lime": return Color(red: 0.52, green: 0.8, blue: 0.09) - case "green": return Color(red: 0.13, green: 0.77, blue: 0.37) - case "blue": return Color(red: 0.23, green: 0.51, blue: 0.96) - case "red": return Color(red: 0.94, green: 0.27, blue: 0.27) + case "lime": return Color(red: 0.52, green: 0.80, blue: 0.09) + case "green": return Color(red: 0.13, green: 0.77, blue: 0.37) + case "blue": return Color(red: 0.23, green: 0.51, blue: 0.96) + case "red": return Color(red: 0.94, green: 0.27, blue: 0.27) case "yellow": return Color(red: 0.92, green: 0.70, blue: 0.03) case "purple": return Color(red: 0.66, green: 0.33, blue: 0.97) - case "pink": return Color(red: 0.93, green: 0.28, blue: 0.60) + case "pink": return Color(red: 0.93, green: 0.28, blue: 0.60) case "orange": return Color(red: 0.98, green: 0.45, blue: 0.09) - default: return Color(red: 0.39, green: 0.40, blue: 0.95) // Indigo + default: return Color(red: 0.39, green: 0.40, blue: 0.95) } } } -/// Expandable group of items +// MARK: - SideNavGroupView + struct SideNavGroupView: View { let group: SideNavGroup let isExpanded: Bool @@ -434,24 +422,21 @@ struct SideNavGroupView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - // Group header Button(action: onToggle) { HStack(spacing: 16) { if let iconName = group.icon { Image(systemName: getIconForName(iconName)) - .font(.system(size: 20)) - .frame(width: 24) + .font(.system(size: 20)) + .frame(width: 24) } - - Text(group.heading) - .fontWeight(.medium) - + Text(group.heading).fontWeight(.medium) Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.secondary) - .rotationEffect(.degrees(isExpanded ? 90 : 0)) + // chevron.forward auto-mirrors with layoutDirection: + // LTR → points right, RTL → points left + Image(systemName: "chevron.forward") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) @@ -460,10 +445,9 @@ struct SideNavGroupView: View { } .buttonStyle(.plain) - // Children (animated) if isExpanded, let children = group.children { VStack(alignment: .leading, spacing: 0) { - ForEach(Array(children.enumerated()), id: \.offset) { index, child in + ForEach(Array(children.enumerated()), id: \.offset) { _, child in if let item = child.data { SideNavItemView( item: item, @@ -473,7 +457,7 @@ struct SideNavGroupView: View { ) .transition(.asymmetric( insertion: .move(edge: .top).combined(with: .opacity), - removal: .move(edge: .top).combined(with: .opacity) + removal: .move(edge: .top).combined(with: .opacity) )) } } diff --git a/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift b/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift index 84be240..ff71c39 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift @@ -17,6 +17,12 @@ struct NativeTopBar: UIViewRepresentable { navigationBar.scrollEdgeAppearance = appearance navigationBar.compactAppearance = appearance + // RTL: Force right-to-left layout on the navigation bar. + // This flips the positions of leftBarButtonItem (moves to right) and + // rightBarButtonItems (move to left), and right-aligns the title — all + // of which is correct for Arabic/RTL navigation bars. + navigationBar.semanticContentAttribute = .forceRightToLeft + // Create navigation item let navItem = UINavigationItem() navigationBar.items = [navItem] @@ -35,14 +41,15 @@ struct NativeTopBar: UIViewRepresentable { func updateUIView(_ navigationBar: UINavigationBar, context: Context) { guard let topBarData = uiState.topBarData, - let navItem = navigationBar.items?.first else { return } + let navItem = navigationBar.items?.first else { return } // Update title if let subtitle = topBarData.subtitle { // Create attributed title with subtitle let titleLabel = UILabel() titleLabel.numberOfLines = 2 - titleLabel.textAlignment = .center + // RTL: .natural aligns text to the right in RTL locales automatically. + titleLabel.textAlignment = .natural let titleText = NSMutableAttributedString() let textColor = topBarData.textColor.flatMap { UIColor(hex: $0) } ?? UIColor.label @@ -69,7 +76,10 @@ struct NativeTopBar: UIViewRepresentable { navItem.title = topBarData.title } - // Update left bar button (navigation icon) + // Update left bar button (navigation/hamburger icon). + // RTL note: with semanticContentAttribute = .forceRightToLeft set in makeUIView, + // leftBarButtonItem is visually rendered on the RIGHT side of the bar — + // which is the correct leading position for RTL Arabic navigation. if topBarData.showNavigationIcon == true && uiState.hasSideNav() { let button = UIBarButtonItem( image: UIImage(systemName: "line.3.horizontal"), @@ -82,7 +92,9 @@ struct NativeTopBar: UIViewRepresentable { navItem.leftBarButtonItem = nil } - // Update right bar buttons (actions) + // Update right bar buttons (actions). + // RTL note: rightBarButtonItems will appear on the LEFT side visually, which is + // the correct trailing position for action buttons in RTL navigation bars. if let actions = topBarData.children, !actions.isEmpty { var barButtonItems: [UIBarButtonItem] = [] @@ -121,12 +133,12 @@ struct NativeTopBar: UIViewRepresentable { } if let bgColorHex = topBarData.backgroundColor, - let bgColor = UIColor(hex: bgColorHex) { + let bgColor = UIColor(hex: bgColorHex) { appearance.backgroundColor = bgColor } if let textColorHex = topBarData.textColor, - let textColor = UIColor(hex: textColorHex) { + let textColor = UIColor(hex: textColorHex) { appearance.titleTextAttributes = [.foregroundColor: textColor] appearance.largeTitleTextAttributes = [.foregroundColor: textColor] // Also set the button tint color to match @@ -164,7 +176,7 @@ struct NativeTopBar: UIViewRepresentable { @objc func actionTapped(_ sender: UIBarButtonItem) { guard let actionId = sender.accessibilityIdentifier, - let url = actionUrls[actionId] else { + let url = actionUrls[actionId] else { return } From 888ff3af96a6748340e31fac04a1e094dc446208 Mon Sep 17 00:00:00 2001 From: Adel Ali <3adeling@gmail.com> Date: Tue, 10 Mar 2026 02:51:49 +0300 Subject: [PATCH 2/4] add RTL support --- resources/xcode/NativePHP/AppDelegate.swift | 4 +- resources/xcode/NativePHP/NativePHPApp.swift | 17 +- .../NativePHP/NativeUI/NativeSideNav.swift | 156 +++++++++--------- 3 files changed, 85 insertions(+), 92 deletions(-) diff --git a/resources/xcode/NativePHP/AppDelegate.swift b/resources/xcode/NativePHP/AppDelegate.swift index c96bc78..9d89674 100644 --- a/resources/xcode/NativePHP/AppDelegate.swift +++ b/resources/xcode/NativePHP/AppDelegate.swift @@ -37,8 +37,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { - let locale = Locale.current.language.languageCode?.identifier ?? "en" - let isRTL = Locale.characterDirection(forLanguage: locale) == .rightToLeft + let language = Locale.preferredLanguages.first ?? "en" + let isRTL = Locale.characterDirection(forLanguage: language) == .rightToLeft let direction: UISemanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight diff --git a/resources/xcode/NativePHP/NativePHPApp.swift b/resources/xcode/NativePHP/NativePHPApp.swift index 8430a0b..c346780 100644 --- a/resources/xcode/NativePHP/NativePHPApp.swift +++ b/resources/xcode/NativePHP/NativePHPApp.swift @@ -81,8 +81,6 @@ struct NativePHPApp: App { // It renders underneath the splash until WebView finishes loading if appState.isReadyToLoad { ContentView() - .environment(\.layoutDirection, .rightToLeft) - .environment(\.locale, Locale(identifier: "ar")) } // Splash overlays until WebView finishes loading (Phase 3) @@ -99,12 +97,15 @@ struct NativePHPApp: App { } } .animation(.easeInOut(duration: 0.3), value: appState.isInitialized) - // RTL: Apply right-to-left layout direction to the entire SwiftUI view hierarchy. - // This covers NativeSideNav, SideNavItemView, SideNavGroupView, and all - // other SwiftUI-based NativeUI components. UIKit components (UITabBar, - // UINavigationBar) are handled separately via UIView.appearance() in AppDelegate. - .environment(\.layoutDirection, .rightToLeft) - .environment(\.locale, Locale(identifier: "ar")) + // Apply layout direction based on device locale. + // RTL languages (Arabic, Hebrew, etc.) get .rightToLeft; everything else gets .leftToRight. + // UIKit components (UITabBar, UINavigationBar) are handled separately + // via UIView.appearance() in AppDelegate. + .environment(\.layoutDirection, { + let language = Locale.preferredLanguages.first ?? "en" + return Locale.characterDirection(forLanguage: language) == .rightToLeft + ? .rightToLeft : .leftToRight + }()) .onOpenURL { url in // Only handle if not already handled by AppDelegate during cold start if !DeepLinkRouter.shared.hasPendingURL() { diff --git a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift index 9bda7b1..56855a1 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift @@ -2,21 +2,24 @@ import SwiftUI /// Bidirectional Side Navigation — supports both LTR and RTL layouts. /// -/// Reads `layoutDirection` from the SwiftUI environment and adapts automatically: -/// - LTR: drawer slides in from the LEFT edge -/// - RTL: drawer slides in from the RIGHT edge +/// RTL: drawer slides in from the RIGHT edge (positive x hides it) +/// LTR: drawer slides in from the LEFT edge (negative x hides it) /// -/// All offset and gesture math is normalised using a `dir` multiplier: -/// dir = -1 (LTR) → negative x hides drawer off-screen left -/// dir = +1 (RTL) → positive x hides drawer off-screen right +/// KEY: SwiftUI's offset(x:) uses SCREEN coordinates and does NOT flip for RTL. +/// hiddenOffset is therefore direction-aware (positive for RTL, negative for LTR). /// -/// slideOffset = 0 → drawer fully visible at its edge -/// slideOffset = drawerWidth * dir → drawer fully hidden off-screen +/// dragOffset is stored in LOGICAL space (positive = more open, 0 = closed extra). +/// It is converted to screen coords only when computing drawerXOffset. struct NativeSideNavigation: View { @ObservedObject var uiState = NativeUIState.shared - private var isRTL: Bool { UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft } + + private var isRTL: Bool { + let language = Locale.preferredLanguages.first ?? "en" + return Locale.characterDirection(forLanguage: language) == .rightToLeft + } + @State private var expandedGroups: Set = [] - @State private var dragOffset: CGFloat = 0 + @State private var dragOffset: CGFloat = 0 // logical space: positive = opening let content: Content let onNavigate: (String) -> Void @@ -24,20 +27,6 @@ struct NativeSideNavigation: View { private let drawerWidthRatio: CGFloat = 0.85 private let edgeSwipeThreshold: CGFloat = 30 - /// +1 for RTL (drawer on right), -1 for LTR (drawer on left). - /// Multiplying any offset or velocity by this normalises direction math. - private var dir: CGFloat { isRTL ? 1 : -1 } - - /// The physical edge the drawer appears on. - private var drawerEdge: Edge { isRTL ? .trailing : .leading } - - /// Safe area inset on the drawer's side. - private func safeInset(_ geometry: GeometryProxy) -> CGFloat { - isRTL - ? geometry.safeAreaInsets.trailing - : geometry.safeAreaInsets.leading - } - init(onNavigate: @escaping (String) -> Void, @ViewBuilder content: () -> Content) { self.onNavigate = onNavigate self.content = content() @@ -45,85 +34,84 @@ struct NativeSideNavigation: View { var body: some View { GeometryReader { geometry in - let isLandscape = geometry.size.width > geometry.size.height - let drawerWidth = geometry.size.width * (isLandscape ? 0.4 : drawerWidthRatio) - let safeArea = safeInset(geometry) - - // Hidden offset: moves drawer fully off-screen on its side. - // dir * (drawerWidth + safeArea + 10) - // LTR → negative x → off-screen LEFT - // RTL → positive x → off-screen RIGHT - let hiddenOffset = dir * (drawerWidth + safeArea + 10) - - // Current drawer x position: - // open: 0 (drawer at edge, fully visible) - // closed: hiddenOffset (drawer off-screen) - let drawerXOffset: CGFloat = { - let base: CGFloat = uiState.shouldPresentSidebar ? 0 : hiddenOffset - return base + dragOffset - }() + let screenW = geometry.size.width + let screenH = geometry.size.height + let isLandscape = screenW > screenH + let drawerWidth = screenW * (isLandscape ? 0.4 : drawerWidthRatio) + + // All positioning uses ABSOLUTE screen coordinates via .position(). + // This is immune to SwiftUI's layoutDirection flipping. + + // slideProgress: 0 = fully open, drawerWidth = fully hidden + let baseSlide: CGFloat = uiState.shouldPresentSidebar ? 0 : drawerWidth + let slideX: CGFloat = max(0, min(drawerWidth, baseSlide + dragOffset)) + + // Drawer center X in screen coordinates: + // RTL open: screenW - drawerWidth/2 (right edge) + // RTL closed: screenW + drawerWidth/2 (off-screen right) + // LTR open: drawerWidth/2 (left edge) + // LTR closed: -drawerWidth/2 (off-screen left) + let openCenterX: CGFloat = isRTL + ? screenW - drawerWidth / 2 + : drawerWidth / 2 + let drawerCenterX: CGFloat = isRTL + ? openCenterX + slideX + : openCenterX - slideX + let centerY = screenH / 2 - // Overlay opacity: 0 when hidden, 0.5 when fully open let overlayOpacity: Double = { - let progress = 1 - abs(drawerXOffset) / drawerWidth + let progress = 1.0 - slideX / drawerWidth return Double(max(0, min(0.5, progress * 0.5))) }() - // Swipe inward from the drawer's edge to open. - // Inward direction = opposite of dir: - // LTR: swipe right (+) from left edge - // RTL: swipe left (-) from right edge + // ── Edge swipe to open ────────────────────────────────────── + // slideX goes from drawerWidth → 0 when opening. + // dragOffset must be NEGATIVE to reduce slideX. + // RTL: swipe LEFT from right edge (translation negative) → dragOffset = translation ✓ + // LTR: swipe RIGHT from left edge (translation positive) → dragOffset = -translation ✓ let edgeSwipeGesture = DragGesture(minimumDistance: 10) .onChanged { value in - // Must start from the correct physical edge let atEdge = isRTL - ? value.startLocation.x > geometry.size.width - edgeSwipeThreshold - : value.startLocation.x < edgeSwipeThreshold - + ? value.startLocation.x > screenW - edgeSwipeThreshold + : value.startLocation.x < edgeSwipeThreshold guard atEdge else { return } - // Normalised translation: negative = inward (opening) - let norm = dir * value.translation.width - guard norm < 0 else { return } - - // dragOffset mirrors the normalised translation back to screen coords - // and clamps so drawer can't go past fully open - dragOffset = max(norm, -abs(hiddenOffset)) * dir + let t = value.translation.width + guard isRTL ? t < 0 : t > 0 else { return } + dragOffset = max(isRTL ? t : -t, -drawerWidth) } .onEnded { value in - let normTranslation = dir * value.translation.width - let normVelocity = dir * (value.predictedEndTranslation.width - value.translation.width) - - if abs(normTranslation) > drawerWidth * 0.3 || normVelocity < -300 { + let t = abs(value.translation.width) + let v = abs(value.predictedEndTranslation.width - value.translation.width) + if t > drawerWidth * 0.3 || v > 300 { open() } else { withAnimation(.easeOut(duration: 0.25)) { dragOffset = 0 } } } - // Swipe outward on drawer or overlay to close. - // Outward direction = same as dir: - // LTR: swipe left (-) → close - // RTL: swipe right (+) → close + // ── Swipe outward to close ────────────────────────────────── + // slideX goes from 0 → drawerWidth when closing. + // dragOffset must be POSITIVE to increase slideX. + // RTL: swipe RIGHT (translation positive) → dragOffset = translation ✓ + // LTR: swipe LEFT (translation negative) → dragOffset = -translation ✓ let closeDragGesture = DragGesture() .onChanged { value in - // Normalised translation: positive = outward (closing) - let norm = dir * value.translation.width - guard norm > 0 else { return } - dragOffset = min(norm, abs(hiddenOffset)) * dir + let t = value.translation.width + guard isRTL ? t > 0 : t < 0 else { return } + dragOffset = min(isRTL ? t : -t, drawerWidth) } .onEnded { value in - let normTranslation = dir * value.translation.width - let normVelocity = dir * (value.predictedEndTranslation.width - value.translation.width) - - if normTranslation > drawerWidth * 0.3 || normVelocity > 300 { + let t = abs(value.translation.width) + let v = abs(value.predictedEndTranslation.width - value.translation.width) + if t > drawerWidth * 0.3 || v > 300 { close() } else { withAnimation(.easeOut(duration: 0.25)) { dragOffset = 0 } } } - ZStack(alignment: isRTL ? .trailing : .leading) { + ZStack { // ── Main content ────────────────────────────────────────────── content .zIndex(0) @@ -141,16 +129,17 @@ struct NativeSideNavigation: View { } // ── Side drawer ─────────────────────────────────────────────── + // .position() uses absolute screen coordinates — no flipping. if uiState.hasSideNav() { drawerContent - .frame(width: drawerWidth) + .frame(width: drawerWidth, height: screenH) .background(Color(.systemBackground)) - .offset(x: drawerXOffset) + .position(x: drawerCenterX, y: centerY) .zIndex(2) .gesture(closeDragGesture) .onAppear { let side = isRTL ? "RIGHT (RTL)" : "LEFT (LTR)" - print("📱 NativeSideNavigation: drawer on \(side) edge") + print("📱 NativeSideNavigation: drawer on \(side) edge, openCenterX=\(openCenterX)") if let children = uiState.sideNavData?.children { for child in children where child.type == "side_nav_group" { if case .group(let group) = child.data, @@ -164,11 +153,15 @@ struct NativeSideNavigation: View { // ── Invisible edge detector for swipe-to-open ──────────────── if uiState.hasSideNav() && !uiState.shouldPresentSidebar { + let detectorX = isRTL + ? screenW - edgeSwipeThreshold / 2 + : edgeSwipeThreshold / 2 + Color.clear - .frame(width: edgeSwipeThreshold) + .frame(width: edgeSwipeThreshold, height: screenH) .contentShape(Rectangle()) .gesture(edgeSwipeGesture) - .ignoresSafeArea(edges: isRTL ? .trailing : .leading) + .position(x: detectorX, y: centerY) .zIndex(3) } } @@ -431,8 +424,7 @@ struct SideNavGroupView: View { } Text(group.heading).fontWeight(.medium) Spacer() - // chevron.forward auto-mirrors with layoutDirection: - // LTR → points right, RTL → points left + // chevron.forward auto-mirrors in RTL (points left in RTL, right in LTR) Image(systemName: "chevron.forward") .font(.system(size: 12, weight: .semibold)) .foregroundColor(.secondary) From 6326b9cfadff12e6bc7727550b936a4392029491 Mon Sep 17 00:00:00 2001 From: Adel Ali <3adeling@gmail.com> Date: Tue, 10 Mar 2026 22:13:17 +0300 Subject: [PATCH 3/4] add RTL support --- resources/xcode/NativePHP/NativeUI/NativeSideNav.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift index 56855a1..fb882b6 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift @@ -12,6 +12,7 @@ import SwiftUI /// It is converted to screen coords only when computing drawerXOffset. struct NativeSideNavigation: View { @ObservedObject var uiState = NativeUIState.shared + @Environment(\.layoutDirection) private var parentLayoutDirection private var isRTL: Bool { let language = Locale.preferredLanguages.first ?? "en" @@ -114,6 +115,7 @@ struct NativeSideNavigation: View { ZStack { // ── Main content ────────────────────────────────────────────── content + .environment(\.layoutDirection, parentLayoutDirection) .zIndex(0) .disabled(uiState.shouldPresentSidebar) @@ -132,6 +134,7 @@ struct NativeSideNavigation: View { // .position() uses absolute screen coordinates — no flipping. if uiState.hasSideNav() { drawerContent + .environment(\.layoutDirection, parentLayoutDirection) .frame(width: drawerWidth, height: screenH) .background(Color(.systemBackground)) .position(x: drawerCenterX, y: centerY) @@ -165,6 +168,7 @@ struct NativeSideNavigation: View { .zIndex(3) } } + .environment(\.layoutDirection, .leftToRight) .onChange(of: uiState.shouldPresentSidebar) { _, newValue in if newValue { withAnimation(.easeInOut(duration: 0.3)) { dragOffset = 0 } From edcf3c11c22e17e27ba3042fd20c01edc614a7bc Mon Sep 17 00:00:00 2001 From: Adel Ali <3adeling@gmail.com> Date: Wed, 11 Mar 2026 00:58:17 +0300 Subject: [PATCH 4/4] enhance RTL support --- config/nativephp.php | 25 +++++++++++++++++ .../mobile/bridge/functions/EdgeFunctions.kt | 8 ++++++ .../com/nativephp/mobile/ui/MainActivity.kt | 17 +++++++++++- .../com/nativephp/mobile/ui/NativeUIState.kt | 12 +++++++++ resources/xcode/NativePHP/AppDelegate.swift | 27 ++++++++++--------- .../Bridge/Functions/EdgeFunctions.swift | 12 +++++++++ resources/xcode/NativePHP/ContentView.swift | 4 +++ resources/xcode/NativePHP/Info.plist | 2 -- resources/xcode/NativePHP/NativePHPApp.swift | 12 +++------ .../NativePHP/NativeUI/NativeBottomNav.swift | 11 +++++--- .../NativePHP/NativeUI/NativeSideNav.swift | 3 +-- .../NativePHP/NativeUI/NativeTopBar.swift | 19 ++++++++----- .../NativePHP/NativeUI/NativeUIState.swift | 16 +++++++++++ src/Commands/BuildIosAppCommand.php | 13 +++++++++ src/Edge/Edge.php | 16 +++++++++-- src/NativeServiceProvider.php | 15 +++++++++++ 16 files changed, 175 insertions(+), 37 deletions(-) diff --git a/config/nativephp.php b/config/nativephp.php index 4fd28b0..8baf088 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -318,6 +318,31 @@ */ 'ipad' => false, + /* + |-------------------------------------------------------------------------- + | RTL Support + |-------------------------------------------------------------------------- + | + | When enabled, the app will respect the device locale for RTL languages + | (Arabic, Hebrew, etc.) and automatically mirror the layout direction. + | When disabled (default), the app always uses LTR regardless of locale. + | + */ + 'rtl_support' => false, + + /* + |-------------------------------------------------------------------------- + | Localizations + |-------------------------------------------------------------------------- + | + | The list of language codes your app supports. These values populate the + | CFBundleLocalizations array in Info.plist for iOS builds, telling the + | system which localizations your app provides. + | + */ + + 'localizations' => ['en'], + /* |-------------------------------------------------------------------------- | Device Orientation Support diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/functions/EdgeFunctions.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/functions/EdgeFunctions.kt index 05ef369..8a35f97 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/functions/EdgeFunctions.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/functions/EdgeFunctions.kt @@ -32,6 +32,14 @@ object EdgeFunctions { return mapOf("error" to "No components provided") } + // Extract _meta.rtl_support flag + @Suppress("UNCHECKED_CAST") + val meta = parameters["_meta"] as? Map + val rtlSupport = meta?.get("rtl_support") as? Boolean + if (rtlSupport != null) { + NativeUIState.setRtlSupport(rtlSupport) + } + Log.d("EdgeFunctions.Set", "🎨 Edge.Set called") try { diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt index 328a6bb..1d0a916 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.* +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -123,7 +124,21 @@ class MainActivity : FragmentActivity(), WebViewProvider { // Set up Compose UI setContent { - MainScreen() + val rtlSupport by NativeUIState.rtlSupport + val layoutDirection = if (rtlSupport) { + // Let the system determine direction from locale + androidx.compose.ui.unit.LayoutDirection.Rtl.takeIf { + resources.configuration.layoutDirection == android.view.View.LAYOUT_DIRECTION_RTL + } ?: androidx.compose.ui.unit.LayoutDirection.Ltr + } else { + androidx.compose.ui.unit.LayoutDirection.Ltr + } + + CompositionLocalProvider( + androidx.compose.ui.platform.LocalLayoutDirection provides layoutDirection + ) { + MainScreen() + } } initializeEnvironmentAsync { diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIState.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIState.kt index 11b5e4c..466aa61 100644 --- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIState.kt +++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/NativeUIState.kt @@ -27,6 +27,10 @@ object NativeUIState { private val _topBarData = mutableStateOf(null) val topBarData: State = _topBarData + // RTL support flag — controlled by nativephp.rtl_support config + private val _rtlSupport = mutableStateOf(false) + val rtlSupport: State = _rtlSupport + // Keyboard visibility state - used to hide bottom nav when keyboard is open private val _isKeyboardVisible = mutableStateOf(false) val isKeyboardVisible: State = _isKeyboardVisible @@ -35,6 +39,14 @@ object NativeUIState { var drawerState: DrawerState? = null var drawerScope: CoroutineScope? = null + /** + * Update RTL support flag from Edge.Set _meta payload + */ + fun setRtlSupport(enabled: Boolean) { + _rtlSupport.value = enabled + Log.d(TAG, "RTL support set to: $enabled") + } + /** * Update keyboard visibility state */ diff --git a/resources/xcode/NativePHP/AppDelegate.swift b/resources/xcode/NativePHP/AppDelegate.swift index 9d89674..8b1af27 100644 --- a/resources/xcode/NativePHP/AppDelegate.swift +++ b/resources/xcode/NativePHP/AppDelegate.swift @@ -31,20 +31,11 @@ extension Notification.Name { class AppDelegate: NSObject, UIApplicationDelegate { static let shared = AppDelegate() - // Called when the app is launched - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - - let language = Locale.preferredLanguages.first ?? "en" - let isRTL = Locale.characterDirection(forLanguage: language) == .rightToLeft - + /// Apply or remove RTL appearance for all UIKit components. + /// Called from NativeUIState when rtlSupport changes. + static func applyRTLAppearance(_ isRTL: Bool) { let direction: UISemanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight - // RTL: Force right-to-left layout direction for all UIKit views app-wide. - // Must be called before any window or view is created so appearance proxies - // take effect on every UIKit component (UITabBar, UINavigationBar, etc.). UIView.appearance().semanticContentAttribute = direction UINavigationBar.appearance().semanticContentAttribute = direction UITabBar.appearance().semanticContentAttribute = direction @@ -52,6 +43,18 @@ class AppDelegate: NSObject, UIApplicationDelegate { UICollectionView.appearance().semanticContentAttribute = direction UITextField.appearance().textAlignment = .natural UILabel.appearance().textAlignment = .natural + } + + // Called when the app is launched + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + + // RTL appearance is applied conditionally via applyRTLAppearance() + // when NativeUIState.rtlSupport is set from the Edge.Set payload. + // Initial state is LTR until the config flag arrives. + AppDelegate.applyRTLAppearance(false) // Check if the app was launched from a URL (custom scheme) if let url = launchOptions?[UIApplication.LaunchOptionsKey.url] as? URL { diff --git a/resources/xcode/NativePHP/Bridge/Functions/EdgeFunctions.swift b/resources/xcode/NativePHP/Bridge/Functions/EdgeFunctions.swift index 21593eb..66e65d4 100644 --- a/resources/xcode/NativePHP/Bridge/Functions/EdgeFunctions.swift +++ b/resources/xcode/NativePHP/Bridge/Functions/EdgeFunctions.swift @@ -26,6 +26,18 @@ enum EdgeFunctions { return ["error": "No components array provided"] } + // Extract _meta.rtl_support flag + if let meta = parameters["_meta"] as? [String: Any], + let rtlSupport = meta["rtl_support"] as? Bool { + if Thread.isMainThread { + NativeUIState.shared.rtlSupport = rtlSupport + } else { + DispatchQueue.main.sync { + NativeUIState.shared.rtlSupport = rtlSupport + } + } + } + print("🎨 Edge.Set called with \(components.count) component(s)") print("🎨 Edge.Set components: \(components)") diff --git a/resources/xcode/NativePHP/ContentView.swift b/resources/xcode/NativePHP/ContentView.swift index 3274b81..8cc357f 100644 --- a/resources/xcode/NativePHP/ContentView.swift +++ b/resources/xcode/NativePHP/ContentView.swift @@ -230,6 +230,9 @@ struct WebView: UIViewRepresentable { let isDarkMode = windowScene?.windows.first?.traitCollection.userInterfaceStyle == .dark let colorScheme = isDarkMode ? "dark" : "light" + let isRTL = NativeUIState.shared.isRTL + let dir = isRTL ? "rtl" : "ltr" + let js = """ (function() { // Set CSS variables directly on documentElement for immediate availability @@ -239,6 +242,7 @@ struct WebView: UIViewRepresentable { document.documentElement.style.setProperty('--inset-bottom', '\(insets.bottom)px'); document.documentElement.style.setProperty('--inset-left', '\(insets.left)px'); document.documentElement.style.setProperty('--native-color-scheme', '\(colorScheme)'); + document.documentElement.setAttribute('dir', '\(dir)'); } })(); """ diff --git a/resources/xcode/NativePHP/Info.plist b/resources/xcode/NativePHP/Info.plist index 6ac6450..8e5359f 100644 --- a/resources/xcode/NativePHP/Info.plist +++ b/resources/xcode/NativePHP/Info.plist @@ -17,8 +17,6 @@ CFBundleLocalizations - ar - en NSAppTransportSecurity diff --git a/resources/xcode/NativePHP/NativePHPApp.swift b/resources/xcode/NativePHP/NativePHPApp.swift index c346780..1a5630a 100644 --- a/resources/xcode/NativePHP/NativePHPApp.swift +++ b/resources/xcode/NativePHP/NativePHPApp.swift @@ -97,15 +97,11 @@ struct NativePHPApp: App { } } .animation(.easeInOut(duration: 0.3), value: appState.isInitialized) - // Apply layout direction based on device locale. - // RTL languages (Arabic, Hebrew, etc.) get .rightToLeft; everything else gets .leftToRight. + // Apply layout direction based on rtl_support config and device locale. + // When rtl_support is enabled, RTL languages get .rightToLeft; otherwise always LTR. // UIKit components (UITabBar, UINavigationBar) are handled separately - // via UIView.appearance() in AppDelegate. - .environment(\.layoutDirection, { - let language = Locale.preferredLanguages.first ?? "en" - return Locale.characterDirection(forLanguage: language) == .rightToLeft - ? .rightToLeft : .leftToRight - }()) + // via AppDelegate.applyRTLAppearance(). + .environment(\.layoutDirection, NativeUIState.shared.isRTL ? .rightToLeft : .leftToRight) .onOpenURL { url in // Only handle if not already handled by AppDelegate during cold start if !DeepLinkRouter.shared.hasPendingURL() { diff --git a/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift b/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift index bbfd5f4..4c3304b 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift @@ -27,9 +27,10 @@ struct NativeUITabBar: UIViewRepresentable { tabBar.scrollEdgeAppearance = appearance } - // RTL: Force right-to-left layout so tab items are ordered right-to-left, - // matching the natural reading direction for Arabic apps. - tabBar.semanticContentAttribute = .forceRightToLeft + // RTL: Conditionally force right-to-left layout so tab items are ordered + // right-to-left, matching the natural reading direction for Arabic apps. + let isRTL = NativeUIState.shared.isRTL + tabBar.semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight // Apply custom active color (tint color for selected items) if let activeColorHex = activeColor, let color = UIColor(hex: activeColorHex) { @@ -96,6 +97,10 @@ struct NativeUITabBar: UIViewRepresentable { } func updateUIView(_ tabBar: UITabBar, context: Context) { + // Update RTL direction reactively (rtlSupport may change after makeUIView) + let isRTL = NativeUIState.shared.isRTL + tabBar.semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight + // Apply custom active color (ensure it persists across updates) if let activeColorHex = activeColor, let color = UIColor(hex: activeColorHex) { tabBar.tintColor = color diff --git a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift index fb882b6..3ef1942 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift @@ -15,8 +15,7 @@ struct NativeSideNavigation: View { @Environment(\.layoutDirection) private var parentLayoutDirection private var isRTL: Bool { - let language = Locale.preferredLanguages.first ?? "en" - return Locale.characterDirection(forLanguage: language) == .rightToLeft + uiState.isRTL } @State private var expandedGroups: Set = [] diff --git a/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift b/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift index ff71c39..9fb9b99 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeTopBar.swift @@ -17,11 +17,11 @@ struct NativeTopBar: UIViewRepresentable { navigationBar.scrollEdgeAppearance = appearance navigationBar.compactAppearance = appearance - // RTL: Force right-to-left layout on the navigation bar. - // This flips the positions of leftBarButtonItem (moves to right) and - // rightBarButtonItems (move to left), and right-aligns the title — all - // of which is correct for Arabic/RTL navigation bars. - navigationBar.semanticContentAttribute = .forceRightToLeft + // RTL: Conditionally force right-to-left layout on the navigation bar. + // When enabled, flips leftBarButtonItem to the right and rightBarButtonItems + // to the left — correct for Arabic/RTL navigation bars. + let isRTL = NativeUIState.shared.isRTL + navigationBar.semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight // Create navigation item let navItem = UINavigationItem() @@ -40,6 +40,10 @@ struct NativeTopBar: UIViewRepresentable { } func updateUIView(_ navigationBar: UINavigationBar, context: Context) { + // Update RTL direction reactively (rtlSupport may change after makeUIView) + let isRTL = NativeUIState.shared.isRTL + navigationBar.semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight + guard let topBarData = uiState.topBarData, let navItem = navigationBar.items?.first else { return } @@ -48,8 +52,9 @@ struct NativeTopBar: UIViewRepresentable { // Create attributed title with subtitle let titleLabel = UILabel() titleLabel.numberOfLines = 2 - // RTL: .natural aligns text to the right in RTL locales automatically. - titleLabel.textAlignment = .natural + // RTL: Force text alignment and semantic direction to match the app's RTL state + titleLabel.semanticContentAttribute = isRTL ? .forceRightToLeft : .forceLeftToRight + titleLabel.textAlignment = isRTL ? .right : .left let titleText = NSMutableAttributedString() let textColor = topBarData.textColor.flatMap { UIColor(hex: $0) } ?? UIColor.label diff --git a/resources/xcode/NativePHP/NativeUI/NativeUIState.swift b/resources/xcode/NativePHP/NativeUI/NativeUIState.swift index ded40da..98874a5 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeUIState.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeUIState.swift @@ -13,6 +13,22 @@ class NativeUIState: ObservableObject { // Cache to prevent unnecessary updates private var lastJsonString: String? + // RTL support flag — controlled by nativephp.rtl_support config + @Published var rtlSupport: Bool = false { + didSet { + // Re-apply UIKit appearance when rtlSupport changes + AppDelegate.applyRTLAppearance(isRTL) + } + } + + /// Whether the app should use RTL layout direction. + /// Returns true only when rtlSupport is enabled AND the device locale is an RTL language. + var isRTL: Bool { + guard rtlSupport else { return false } + let language = Locale.preferredLanguages.first ?? "en" + return Locale.characterDirection(forLanguage: language) == .rightToLeft + } + // Sidebar presentation control (for JavaScript access) @Published var shouldPresentSidebar = false diff --git a/src/Commands/BuildIosAppCommand.php b/src/Commands/BuildIosAppCommand.php index 1b3ba4c..996afd3 100644 --- a/src/Commands/BuildIosAppCommand.php +++ b/src/Commands/BuildIosAppCommand.php @@ -514,6 +514,19 @@ private function updateInfoPlistFile(string $filePath, string $appId, ?string $d // Handle UIBackgroundModes $this->updateBackgroundModes($rootDict, $plistData, $pushNotificationsEnabled); + // Update CFBundleLocalizations from config + $localizations = config('nativephp.localizations', ['en']); + if (isset($plistData['CFBundleLocalizations'])) { + $locArray = $plistData['CFBundleLocalizations']['valueNode']; + while ($locArray->firstChild) { + $locArray->removeChild($locArray->firstChild); + } + foreach ($localizations as $locale) { + $stringNode = $locArray->ownerDocument->createElement('string', $locale); + $locArray->appendChild($stringNode); + } + } + // Handle BIFROST_APP_ID $bifrostAppId = env('BIFROST_APP_ID'); if ($bifrostAppId) { diff --git a/src/Edge/Edge.php b/src/Edge/Edge.php index 9079cbf..910ebe7 100644 --- a/src/Edge/Edge.php +++ b/src/Edge/Edge.php @@ -129,7 +129,13 @@ public static function set(): void } if (function_exists('nativephp_call')) { - nativephp_call('Edge.Set', json_encode(['components' => $nativeUIData])); + $payload = [ + 'components' => $nativeUIData, + '_meta' => [ + 'rtl_support' => config('nativephp.rtl_support', false), + ], + ]; + nativephp_call('Edge.Set', json_encode($payload)); } self::reset(); @@ -138,7 +144,13 @@ public static function set(): void public static function clear(): void { if (function_exists('nativephp_call')) { - nativephp_call('Edge.Set', json_encode(['components' => []])); + $payload = [ + 'components' => [], + '_meta' => [ + 'rtl_support' => config('nativephp.rtl_support', false), + ], + ]; + nativephp_call('Edge.Set', json_encode($payload)); } } } diff --git a/src/NativeServiceProvider.php b/src/NativeServiceProvider.php index 333027e..54ad6e5 100644 --- a/src/NativeServiceProvider.php +++ b/src/NativeServiceProvider.php @@ -152,6 +152,21 @@ protected function registerBladeDirectives(): void Blade::if('android', function () { return Facades\System::isAndroid(); }); + + Blade::directive('nativeHead', function () { + return 'getLocale()); + $__dir = "ltr"; + if (config("nativephp.rtl_support", false)) { + $__rtlLangs = ["ar", "he", "fa", "ur", "ps", "ku", "sd", "yi", "dv", "ckb"]; + $__langPrefix = explode("-", $__locale)[0]; + if (in_array($__langPrefix, $__rtlLangs)) { + $__dir = "rtl"; + } + } + echo "lang=\"{$__locale}\" dir=\"{$__dir}\""; + ?>'; + }); } protected function registerMiddleware(): void