diff --git a/config/nativephp.php b/config/nativephp.php index 7ae7ca0..d286572 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -322,6 +322,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 5aee8de..8a67b21 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 @@ -39,6 +39,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 @@ -125,7 +126,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 0295973..8b1af27 100644 --- a/resources/xcode/NativePHP/AppDelegate.swift +++ b/resources/xcode/NativePHP/AppDelegate.swift @@ -31,11 +31,31 @@ extension Notification.Name { class AppDelegate: NSObject, UIApplicationDelegate { static let shared = AppDelegate() + /// 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 + + 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 + } + // 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 { + + // 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 { DebugLogger.shared.log("📱 AppDelegate: Cold start with custom scheme URL: \(url)") @@ -45,9 +65,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 +78,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 +96,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 +107,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { } func application( - _ application: UIApplication, - didFailToRegisterForRemoteNotificationsWithError error: Error + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error ) { NotificationCenter.default.post( name: .didFailToRegisterForRemoteNotifications, @@ -99,9 +119,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/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 fe310f6..6a6e71c 100644 --- a/resources/xcode/NativePHP/ContentView.swift +++ b/resources/xcode/NativePHP/ContentView.swift @@ -250,6 +250,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 @@ -259,6 +262,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 7d6768e..8e5359f 100644 --- a/resources/xcode/NativePHP/Info.plist +++ b/resources/xcode/NativePHP/Info.plist @@ -15,6 +15,9 @@ + CFBundleLocalizations + + NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/resources/xcode/NativePHP/NativePHPApp.swift b/resources/xcode/NativePHP/NativePHPApp.swift index f4464cb..5afd2dd 100644 --- a/resources/xcode/NativePHP/NativePHPApp.swift +++ b/resources/xcode/NativePHP/NativePHPApp.swift @@ -130,17 +130,22 @@ struct NativePHPApp: App { // 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) + // 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 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() { @@ -197,7 +202,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 "/" } @@ -205,11 +210,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 / @@ -229,9 +234,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") @@ -251,7 +256,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 @@ -341,12 +346,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 } @@ -430,8 +435,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..4c3304b 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeBottomNav.swift @@ -27,6 +27,11 @@ struct NativeUITabBar: UIViewRepresentable { tabBar.scrollEdgeAppearance = appearance } + // 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) { tabBar.tintColor = color @@ -92,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 @@ -241,7 +250,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 +279,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 +355,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..3ef1942 100644 --- a/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift +++ b/resources/xcode/NativePHP/NativeUI/NativeSideNav.swift @@ -1,16 +1,30 @@ import SwiftUI -/// Dynamic Side Navigation using slide-in drawer with swipe gesture +/// Bidirectional Side Navigation — supports both LTR and RTL layouts. +/// +/// RTL: drawer slides in from the RIGHT edge (positive x hides it) +/// LTR: drawer slides in from the LEFT edge (negative x hides it) +/// +/// 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). +/// +/// 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 + @Environment(\.layoutDirection) private var parentLayoutDirection + + private var isRTL: Bool { + uiState.isRTL + } + @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 - // Drawer dimensions - private let drawerWidthRatio: CGFloat = 0.85 // 85% of screen width + private let drawerWidthRatio: CGFloat = 0.85 private let edgeSwipeThreshold: CGFloat = 30 init(onNavigate: @escaping (String) -> Void, @ViewBuilder content: () -> Content) { @@ -20,202 +34,192 @@ 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 drawerXOffset: CGFloat = { - let safeAreaOffset = geometry.safeAreaInsets.leading - let baseOffset = uiState.shouldPresentSidebar ? 0 : -(drawerWidth + safeAreaOffset + 10) - return baseOffset + 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 based on drawer position 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))) }() - // Edge swipe gesture to open drawer + // ── 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 - // 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 + let atEdge = isRTL + ? value.startLocation.x > screenW - edgeSwipeThreshold + : value.startLocation.x < edgeSwipeThreshold + guard atEdge else { return } + + let t = value.translation.width + guard isRTL ? t < 0 : t > 0 else { return } + dragOffset = max(isRTL ? t : -t, -drawerWidth) + } + .onEnded { value in + 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 } } + } - // 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) - } - } - .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 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 + let t = value.translation.width + guard isRTL ? t > 0 : t < 0 else { return } + dragOffset = min(isRTL ? t : -t, drawerWidth) + } + .onEnded { value in + 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: .leading) { - // Main content + ZStack { + // ── Main content ────────────────────────────────────────────── content - .zIndex(0) - .disabled(uiState.shouldPresentSidebar) + .environment(\.layoutDirection, parentLayoutDirection) + .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 ─────────────────────────────────────────────── + // .position() uses absolute screen coordinates — no flipping. 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) - } + .environment(\.layoutDirection, parentLayoutDirection) + .frame(width: drawerWidth, height: screenH) + .background(Color(.systemBackground)) + .position(x: drawerCenterX, y: centerY) + .zIndex(2) + .gesture(closeDragGesture) + .onAppear { + let side = isRTL ? "RIGHT (RTL)" : "LEFT (LTR)" + 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, + 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 { + let detectorX = isRTL + ? screenW - edgeSwipeThreshold / 2 + : edgeSwipeThreshold / 2 + Color.clear - .frame(width: edgeSwipeThreshold) - .contentShape(Rectangle()) - .gesture(edgeSwipeGesture) - .ignoresSafeArea(edges: .leading) - .zIndex(3) + .frame(width: edgeSwipeThreshold, height: screenH) + .contentShape(Rectangle()) + .gesture(edgeSwipeGesture) + .position(x: detectorX, y: centerY) + .zIndex(3) } } + .environment(\.layoutDirection, .leftToRight) .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 +236,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 +243,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 +264,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 +309,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 +336,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 +371,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 +387,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 +418,20 @@ 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 in RTL (points left in RTL, right in LTR) + 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 +440,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 +452,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..9fb9b99 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: 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() navigationBar.items = [navItem] @@ -34,15 +40,21 @@ 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 } + 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: 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 @@ -69,7 +81,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 +97,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 +138,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 +181,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 } 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 dffc641..b028541 100644 --- a/src/Commands/BuildIosAppCommand.php +++ b/src/Commands/BuildIosAppCommand.php @@ -541,6 +541,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 d1a412e..ad627e8 100644 --- a/src/NativeServiceProvider.php +++ b/src/NativeServiceProvider.php @@ -159,6 +159,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