diff --git a/android/app/build.gradle b/android/app/build.gradle index 6ea1c6df4e..c4dd50e58d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -2,6 +2,7 @@ apply plugin: 'com.android.application' apply from: 'capacitor.build.gradle' apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' android { namespace "com.logseq.app" @@ -26,6 +27,14 @@ android { } } + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion rootProject.ext.composeCompilerVersion + } + kotlinOptions { jvmTarget = '21' } @@ -42,6 +51,16 @@ dependencies { implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation fileTree(include: ['*.jar'], dir: 'libs') implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation platform("androidx.compose:compose-bom:$composeBomVersion") + implementation "androidx.activity:activity-compose:$androidxActivityVersion" + implementation "androidx.compose.ui:ui" + implementation "androidx.compose.foundation:foundation" + implementation "androidx.compose.material3:material3" + implementation "androidx.compose.material:material-icons-extended" + implementation "androidx.compose.runtime:runtime" + implementation "androidx.compose.animation:animation" + implementation "androidx.navigation:navigation-compose:$androidxNavigationVersion" + implementation "com.google.android.material:material:$materialVersion" implementation project(':capacitor-android') implementation 'androidx.documentfile:documentfile:1.0.1' testImplementation "junit:junit:$junitVersion" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 476c0fb551..6de9f0e50a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -24,7 +24,7 @@ android:name="com.logseq.app.MainActivity" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation" android:exported="true" - android:windowSoftInputMode="adjustNothing" + android:windowSoftInputMode="adjustResize" android:label="@string/title_activity_main" android:launchMode="singleTask" android:theme="@style/AppTheme.NoActionBarLaunch"> diff --git a/android/app/src/main/java/com/logseq/app/ComposeHost.kt b/android/app/src/main/java/com/logseq/app/ComposeHost.kt new file mode 100644 index 0000000000..944999c644 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/ComposeHost.kt @@ -0,0 +1,302 @@ +package com.logseq.app + +import android.graphics.Color +import android.app.Activity +import android.net.Uri +import android.util.Log +import android.view.ViewGroup +import android.webkit.WebView +import android.widget.FrameLayout +import androidx.activity.compose.BackHandler +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val ROOT_ROUTE = "web/{encodedPath}" + +data class NavigationEvent( + val navigationType: String, + val path: String +) + +/** + * Hosts the existing WebView inside Compose and drives Compose Navigation + * so we get back gestures/animations while delegating actual routing to the JS layer. + */ +object ComposeHost { + private val navEvents = MutableSharedFlow(extraBufferCapacity = 64) + + fun applyNavigation(navigationType: String?, path: String?) { + val type = (navigationType ?: "push").lowercase() + val safePath = path?.takeIf { it.isNotBlank() } ?: "/" + navEvents.tryEmit(NavigationEvent(type, safePath)) + } + + fun renderWithSystemInsets( + activity: Activity, + webView: WebView, + onBackRequested: () -> Unit, + onExit: () -> Unit = { activity.finish() } + ) { + WebViewSnapshotManager.registerWindow(activity.window) + val root = activity.findViewById(android.R.id.content) + + // WebView already created by BridgeActivity; just reparent it into Compose. + (webView.parent as? ViewGroup)?.removeView(webView) + + val composeView = ComposeView(activity).apply { + tag = "compose-host-webview" + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + ComposeNavigationHost( + navEvents = navEvents, + webView = webView, + onBackRequested = onBackRequested, + onExit = onExit + ) + } + } + + if (root.findViewWithTag("compose-host-webview") == null) { + root.addView( + composeView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + } + } +} + +private fun encodePath(path: String): String = + Uri.encode(if (path.isBlank()) "/" else path) + +private fun routeFor(path: String): String = + "web/${encodePath(path)}" + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun ComposeNavigationHost( + navEvents: SharedFlow, + webView: WebView, + onBackRequested: () -> Unit, + onExit: () -> Unit +) { + val navController = rememberNavController() + + // Track the last navigation type so we can change slide direction. + val lastNavTypeState = remember { mutableStateOf("push") } + + HandleNavigationEvents( + navController = navController, + navEvents = navEvents, + webView = webView + ) { type -> + lastNavTypeState.value = type + } + + // You can comment this out if you want to rely purely on JS for back. + + NavHost( + navController = navController, + startDestination = ROOT_ROUTE, + modifier = Modifier + .fillMaxSize() + .padding(WindowInsets.systemBars.asPaddingValues()) + ) { + composable( + route = ROOT_ROUTE, + arguments = listOf( + navArgument("encodedPath") { + defaultValue = encodePath("/") + } + ), + // ---- PUSH: A -> B ---- + enterTransition = { + if (lastNavTypeState.value == "pop") { + slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth / 3 }, + animationSpec = tween(220) + ) + fadeIn(animationSpec = tween(180)) + } else { + slideInHorizontally( + initialOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(220) + ) + fadeIn(animationSpec = tween(180)) + } + }, + exitTransition = { + if (lastNavTypeState.value == "pop") { + slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(200) + ) + fadeOut(animationSpec = tween(160)) + } else { + slideOutHorizontally( + targetOffsetX = { fullWidth -> -fullWidth / 4 }, + animationSpec = tween(220) + ) + fadeOut(animationSpec = tween(180)) + } + }, + // ---- POP: B -> A ---- + popEnterTransition = { + slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth / 4 }, + animationSpec = tween(200) + ) + fadeIn(animationSpec = tween(160)) + }, + popExitTransition = { + slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(200) + ) + fadeOut(animationSpec = tween(160)) + } + ) { + AndroidView( + factory = { context -> + FrameLayout(context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + + val webContainer = FrameLayout(context).apply { + id = R.id.webview_container + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + } + + val overlayContainer = FrameLayout(context).apply { + id = R.id.webview_overlay_container + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + isClickable = false + isFocusable = false + } + + overlayContainer.setBackgroundColor(Color.TRANSPARENT) + overlayContainer.alpha = 1f + overlayContainer.visibility = android.view.View.GONE + + addView(webContainer) + addView(overlayContainer) + + WebViewSnapshotManager.registerOverlay(overlayContainer) + + (webView.parent as? ViewGroup)?.removeView(webView) + webContainer.addView( + webView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + } + }, + modifier = Modifier.fillMaxSize(), + update = { root -> + val webContainer = + root.findViewById(R.id.webview_container) + val overlayContainer = + root.findViewById(R.id.webview_overlay_container) + WebViewSnapshotManager.registerOverlay(overlayContainer) + if (webView.parent !== webContainer) { + (webView.parent as? ViewGroup)?.removeView(webView) + webContainer.addView( + webView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + } + } + ) + } + } +} + +@Composable +private fun HandleNavigationEvents( + navController: NavHostController, + navEvents: SharedFlow, + webView: WebView, + onNavType: (String) -> Unit +) { + LaunchedEffect(navController) { + var snapshotVersion = 0 + navEvents.collect { event -> + snapshotVersion += 1 + val currentSnapshotVersion = snapshotVersion + WebViewSnapshotManager.showSnapshot("navigation", webView) + onNavType(event.navigationType) + val route = routeFor(event.path) + when (event.navigationType) { + "push" -> navController.navigate(route) + + "replace" -> { + navController.popBackStack() + navController.navigate(route) { + launchSingleTop = true + } + } + + "pop" -> { + if (!navController.popBackStack()) { + // Already at root; nothing to pop. + } + } + + "reset" -> { + navController.popBackStack(route = ROOT_ROUTE, inclusive = false) + navController.navigate(route) { + popUpTo(ROOT_ROUTE) { + inclusive = true + } + launchSingleTop = true + } + } + + else -> navController.navigate(route) + } + + launch { + delay(260) + if (currentSnapshotVersion == snapshotVersion) { + WebViewSnapshotManager.clearSnapshot("navigation") + } + } + } + } +} diff --git a/android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt b/android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt new file mode 100644 index 0000000000..48900cd57e --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/LiquidTabsPlugin.kt @@ -0,0 +1,534 @@ +package com.logseq.app + +import android.graphics.Color +import android.text.Editable +import android.text.TextWatcher +import android.view.Gravity +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.graphics.Color as ComposeColor +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp // New Import for DP units +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnNextLayout +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin + +// NOTE: NativeUiUtils and MaterialIconResolver are assumed to be defined elsewhere in your project +// and are necessary for this code to compile. + +@CapacitorPlugin(name = "LiquidTabsPlugin") +class LiquidTabsPlugin : Plugin() { + private var bottomNav: ComposeView? = null + private var searchContainer: LinearLayout? = null + private var searchInput: EditText? = null + private var resultsContainer: LinearLayout? = null + private var closeButton: TextView? = null + private var originalBottomPadding: Int? = null + + private var tabsState by mutableStateOf>(emptyList()) + private var currentTabId by mutableStateOf(null) + + // Define a standard horizontal padding for consistency + private val HORIZONTAL_PADDING_DP = 16f + private val VERTICAL_PADDING_DP = 12f + private val RESULT_ROW_VERTICAL_PADDING_DP = 10f + + // 💡 NEW: Define padding for the Tab Bar edges (makes it compact and adds left/right space) + private val TAB_BAR_HORIZONTAL_PADDING = 12.dp + private val ACCENT_COLOR_HEX = "#6097c7" + + @PluginMethod + fun configureTabs(call: PluginCall) { + val activity = activity ?: run { + call.reject("No activity") + return + } + val tabs = parseTabs(call.getArray("tabs")) + + activity.runOnUiThread { + tabsState = tabs + val activeId = currentTabId?.takeIf { id -> tabs.any { it.id == id } } + ?: tabs.firstOrNull()?.id + currentTabId = activeId + ensureNav() + currentTabId?.let { id -> + tabsState.find { it.id == id }?.let { tab -> + handleSelection(tab, reselected = false) + } + } ?: hideSearchUi() + adjustWebViewPadding() + call.resolve() + } + } + + @PluginMethod + fun selectTab(call: PluginCall) { + val id = call.getString("id") ?: run { + call.reject("Missing id") + return + } + val tab = tabsState.find { it.id == id } + if (tab == null) { + call.resolve() + return + } + val nav = bottomNav + if (nav == null) { + call.resolve() + return + } + nav.post { + val reselected = currentTabId == tab.id + handleSelection(tab, reselected) + call.resolve() + } + } + + @PluginMethod + fun updateNativeSearchResults(call: PluginCall) { + val results = parseResults(call.getArray("results")) + activity?.runOnUiThread { + ensureSearchUi() + val container = resultsContainer ?: return@runOnUiThread + container.removeAllViews() + results.forEach { result -> + container.addView(makeResultRow(result)) + } + call.resolve() + } ?: call.resolve() + } + + /** + * FIX: Allows the web view to explicitly show the search UI again, + * typically after backing out of an opened search result item. + */ + @PluginMethod + fun showSearchUiNative(call: PluginCall) { + activity?.runOnUiThread { + showSearchUi() + // Ensure padding is correct when search UI is manually shown + adjustWebViewPadding() + call.resolve() + } ?: call.resolve() + } + + + private fun ensureNav(): ComposeView { + val activity = activity ?: throw IllegalStateException("No activity") + val root = NativeUiUtils.contentRoot(activity) + val nav = bottomNav ?: ComposeView(activity).also { view -> + view.id = R.id.liquid_tabs_bottom_nav // Assuming R.id.liquid_tabs_bottom_nav is defined + view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + view.layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM + ) + view.setBackgroundColor(LogseqTheme.current().background) + bottomNav = view + root.addView(view) + setupImeBehaviorForNav(view) + } + + nav.setContent { + BottomNavBar( + tabs = tabsState, + currentId = currentTabId, + onSelect = { tab -> + val reselected = tab.id == currentTabId + handleSelection(tab, reselected) + } + ) + } + + nav.doOnNextLayout { adjustWebViewPadding() } + return nav + } + + private fun handleSelection(tab: TabSpec, reselected: Boolean) { + currentTabId = tab.id + if (tab.role == "search") { + showSearchUi() + } else { + hideSearchUi() + } + + notifyListeners("tabSelected", JSObject().put("id", tab.id).put("reselected", reselected)) + } + + private fun adjustWebViewPadding() { + val webView = bridge.webView ?: return + val nav = bottomNav ?: return + if (originalBottomPadding == null) { + originalBottomPadding = webView.paddingBottom + } + nav.post { + val padding = originalBottomPadding ?: 0 + val h = nav.height + val newPadding = if (searchContainer?.visibility == View.VISIBLE) { + padding + } else { + padding + h + } + webView.setPadding(webView.paddingLeft, webView.paddingTop, webView.paddingRight, newPadding) + } + } + + private fun setupImeBehaviorForNav(nav: View) { + ViewCompat.setOnApplyWindowInsetsListener(nav) { v, insets -> + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) + val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) + + val extra = if (imeVisible) { + imeInsets.bottom + } else { + 0 + } + v.translationY = extra.toFloat() + insets + } + + ViewCompat.requestApplyInsets(nav) + } + + private fun ensureSearchUi() { + if (searchContainer != null) return + showSearchUi() + } + + private fun showSearchUi() { + val activity = activity ?: return + val root = NativeUiUtils.contentRoot(activity) + val theme = LogseqTheme.current() + val labelColor = if (theme.isDark) theme.tint else Color.BLACK + val secondaryLabelColor = + if (theme.isDark) Color.argb(200, 245, 247, 250) else Color.DKGRAY + + // Calculate status bar height for safe area padding + val insets = ViewCompat.getRootWindowInsets(root) + val statusBarHeight = insets?.getInsets(WindowInsetsCompat.Type.statusBars())?.top ?: 0 + + val container = searchContainer ?: LinearLayout(activity).also { layout -> + layout.orientation = LinearLayout.VERTICAL + layout.setBackgroundColor(theme.background) + + val lp = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + // Set bottom margin to clear the bottom navigation bar + lp.setMargins(0, 0, 0, bottomNav?.height ?: NativeUiUtils.dp(activity, 56f)) + + // Remove elevation/shadow + layout.elevation = 0f + + // Apply status bar height as top padding for safe area + layout.setPadding(0, statusBarHeight, 0, 0) + + root.addView(layout, lp) + searchContainer = layout + } + + // Re-apply top padding in case insets were not available on first run + container.setPadding(0, statusBarHeight, 0, 0) + + // Search Input Setup + if (searchInput == null) { + // Container for input and close button + val searchRow = LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL // Center items vertically + } + + val input = EditText(activity).apply { + hint = "Search" + setSingleLine(true) + setTextColor(labelColor) + setHintTextColor(secondaryLabelColor) + // Remove EditText default background/border for a flat look + setBackgroundColor(Color.TRANSPARENT) + + // Fine-tune padding inside the EditText for text alignment + setPadding( + NativeUiUtils.dp(activity, 0f), + NativeUiUtils.dp(activity, 10f), + NativeUiUtils.dp(activity, 0f), + NativeUiUtils.dp(activity, 10f) + ) + + // Layout params to make EditText take most of the horizontal space + layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.0f) + + setOnKeyListener { _, keyCode, event -> + if (event.action == KeyEvent.ACTION_DOWN) { + when (keyCode) { + KeyEvent.KEYCODE_DEL -> notifyListeners("keyboardHackKey", JSObject().put("key", "backspace")) + KeyEvent.KEYCODE_ENTER -> notifyListeners("keyboardHackKey", JSObject().put("key", "enter")) + } + } + false + } + addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) { + // Toggle close button visibility based on text + val hasText = !s.isNullOrEmpty() + closeButton?.visibility = if (hasText) View.VISIBLE else View.GONE + } + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + notifyListeners("searchChanged", JSObject().put("query", s?.toString() ?: "")) + } + }) + } + + // Close Button + val button = TextView(activity).apply { + text = "X" // Close icon (using simple 'X') + setTextColor(secondaryLabelColor) + textSize = 18f + gravity = Gravity.CENTER + setPadding( + NativeUiUtils.dp(activity, 8f), + NativeUiUtils.dp(activity, 8f), + NativeUiUtils.dp(activity, 8f), + NativeUiUtils.dp(activity, 8f) + ) + visibility = View.GONE // Initially hidden + + setOnClickListener { + input.setText("") // Clear the EditText + // TextWatcher will handle notifying the web view and hiding the button + } + // Set layout params for the button + layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + } + + // 1. Add EditText + searchRow.addView(input) + // 2. Add Close Button + searchRow.addView(button) + + // inputContainer (was the old search container wrapper) + val inputContainer = LinearLayout(activity).apply { + // Add horizontal padding for the search box container + setPadding( + NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP), + NativeUiUtils.dp(activity, VERTICAL_PADDING_DP), + NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP), + NativeUiUtils.dp(activity, VERTICAL_PADDING_DP) + ) + orientation = LinearLayout.VERTICAL + // Add the new searchRow (input + button) + addView(searchRow, 0, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + + // Add a divider below the search box (optional visual polish) + val divider = View(activity).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, NativeUiUtils.dp(activity, 1f)) + setBackgroundColor( + if (theme.isDark) Color.argb(40, 245, 247, 250) else Color.parseColor("#E0E0E0") + ) + } + addView(divider, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, NativeUiUtils.dp(activity, 1f))) + } + + // Insert the inputContainer into the main searchContainer + container.addView(inputContainer, 0, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + searchInput = input + closeButton = button + } + + // Search Results Setup + if (resultsContainer == null) { + val scroll = ScrollView(activity) + val inner = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + // Apply horizontal padding for the list of results + setPadding( + NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP), // Left + NativeUiUtils.dp(activity, 0f), // Top + NativeUiUtils.dp(activity, HORIZONTAL_PADDING_DP), // Right + NativeUiUtils.dp(activity, 12f) // Bottom + ) + } + scroll.addView(inner, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + // The ScrollView should take up the rest of the vertical space + container.addView(scroll, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + resultsContainer = inner + } + + container.visibility = View.VISIBLE + } + + private fun hideSearchUi() { + searchContainer?.visibility = View.GONE + } + + private fun makeResultRow(result: SearchResult): View { + val activity = activity ?: throw IllegalStateException("No activity") + val theme = LogseqTheme.current() + val labelColor = if (theme.isDark) theme.tint else Color.BLACK + val secondaryLabelColor = + if (theme.isDark) Color.argb(200, 245, 247, 250) else Color.DKGRAY + return LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + + // Apply vertical padding for the row item, using RESULT_ROW_VERTICAL_PADDING_DP (10f) + // for both top and bottom to ensure they are equal. + setPadding(0, + NativeUiUtils.dp(activity, RESULT_ROW_VERTICAL_PADDING_DP), // TOP: 10f + 0, + NativeUiUtils.dp(activity, RESULT_ROW_VERTICAL_PADDING_DP) // BOTTOM: 10f + ) + + val subtitleText = result.subtitle + if (subtitleText != null && + !subtitleText.isNullOrBlank() && + subtitleText.lowercase() != "null") { + + val sub = TextView(activity).apply { + text = subtitleText + setTextColor(secondaryLabelColor) + textSize = 13f + } + addView(sub) + } + + val titleView = TextView(activity).apply { + text = result.title + setTextColor(labelColor) + textSize = 15f + } + addView(titleView) + + setOnClickListener { + hideSearchUi() + notifyListeners("openSearchResultBlock", JSObject().put("id", result.id)) + } + } + } + + @Composable + private fun BottomNavBar( + tabs: List, + currentId: String?, + onSelect: (TabSpec) -> Unit + ) { + val theme by LogseqTheme.colors.collectAsState() + val container = ComposeColor(theme.background) + val unselected = if (theme.isDark) ComposeColor(theme.tint).copy(alpha = 0.78f) else ComposeColor.Black.copy(alpha = 0.65f) + Box( + modifier = Modifier + .fillMaxWidth() + .background(container) + .padding(horizontal = TAB_BAR_HORIZONTAL_PADDING) + ) { + NavigationBar( + modifier = Modifier.fillMaxWidth(), + containerColor = container + ) { + tabs.forEach { tab -> + val selected = tab.id == currentId + val icon = remember(tab.systemImage, tab.id) { + MaterialIconResolver.resolve(tab.systemImage) ?: MaterialIconResolver.resolve(tab.id) + } + val accent = ComposeColor(NativeUiUtils.parseColor(ACCENT_COLOR_HEX, Color.parseColor(ACCENT_COLOR_HEX))) + + NavigationBarItem( + selected = selected, + onClick = { onSelect(tab) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = accent, + selectedTextColor = accent, + unselectedIconColor = unselected, + unselectedTextColor = unselected, + indicatorColor = accent.copy(alpha = 0.12f) + ), + icon = { + Icon( + imageVector = icon ?: Icons.Filled.Circle, + contentDescription = tab.title + ) + }, + // Slightly reduce the default Material3 gap between icon and label. + label = { Text(tab.title, modifier = Modifier.offset(y = (-4).dp)) } + ) + } + } + } + } + + private fun parseTabs(array: JSArray?): List { + if (array == null) return emptyList() + val result = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + val id = obj.optString("id", "") + if (id.isBlank()) continue + val title = obj.optString("title", id) + val systemImage = obj.optString("systemImage", "") + val role = obj.optString("role", "normal") + result.add(TabSpec(id, title, systemImage, role)) + } + return result + } + + private fun parseResults(array: JSArray?): List { + if (array == null) return emptyList() + val result = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + val id = obj.optString("id", "") + if (id.isBlank()) continue + val title = obj.optString("title", "") + val subtitle = obj.optString("subtitle", null) + result.add(SearchResult(id, title, subtitle)) + } + return result + } +} + +data class TabSpec( + val id: String, + val title: String, + val systemImage: String, + val role: String +) + +data class SearchResult( + val id: String, + val title: String, + val subtitle: String? +) diff --git a/android/app/src/main/java/com/logseq/app/LogseqTheme.kt b/android/app/src/main/java/com/logseq/app/LogseqTheme.kt new file mode 100644 index 0000000000..ef83e00fe7 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/LogseqTheme.kt @@ -0,0 +1,44 @@ +package com.logseq.app + +import android.content.Context +import android.content.res.Configuration +import android.graphics.Color +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class LogseqThemeColors( + val background: Int, + val tint: Int, + val isDark: Boolean, +) + +object LogseqTheme { + private val _colors = MutableStateFlow(compute(isDark = false)) + val colors: StateFlow = _colors.asStateFlow() + + fun update(context: Context) { + _colors.value = compute(context) + } + + fun current(): LogseqThemeColors = _colors.value + + fun isDark(context: Context): Boolean { + val mask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return mask == Configuration.UI_MODE_NIGHT_YES + } + + private fun compute(context: Context): LogseqThemeColors = compute(isDark(context)) + + private fun compute(isDark: Boolean): LogseqThemeColors { + val background = + if (isDark) Color.parseColor("#002B36") else Color.parseColor("#FCFCFC") + val tint = + if (isDark) Color.parseColor("#F5F7FA") else Color.parseColor("#000000") + return LogseqThemeColors( + background = background, + tint = tint, + isDark = isDark, + ) + } +} diff --git a/android/app/src/main/java/com/logseq/app/MainActivity.java b/android/app/src/main/java/com/logseq/app/MainActivity.java index 44bcf7da32..06db9803dd 100644 --- a/android/app/src/main/java/com/logseq/app/MainActivity.java +++ b/android/app/src/main/java/com/logseq/app/MainActivity.java @@ -1,16 +1,21 @@ package com.logseq.app; import android.content.Intent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.IntentFilter; import android.content.res.Configuration; -import android.os.Build; import android.os.Bundle; -import android.view.View; -import android.view.Window; import android.webkit.ValueCallback; import android.webkit.WebView; +import androidx.activity.EdgeToEdge; +import androidx.core.view.WindowInsetsControllerCompat; import com.getcapacitor.PluginCall; import com.getcapacitor.JSObject; import com.getcapacitor.BridgeActivity; +import androidx.activity.OnBackPressedDispatcher; +import android.util.Log; +import android.view.View; import java.util.Timer; import java.util.TimerTask; @@ -18,17 +23,55 @@ import java.util.TimerTask; import ee.forgr.capacitor_navigation_bar.NavigationBarPlugin; public class MainActivity extends BridgeActivity { + private NavigationCoordinator navigationCoordinator = new NavigationCoordinator(); + private BroadcastReceiver routeChangeReceiver; + @Override public void onCreate(Bundle savedInstanceState) { registerPlugin(FolderPicker.class); registerPlugin(UILocal.class); + registerPlugin(NativeTopBarPlugin.class); + registerPlugin(NativeBottomSheetPlugin.class); + registerPlugin(NativeEditorToolbarPlugin.class); + registerPlugin(NativeSelectionActionBarPlugin.class); + registerPlugin(LiquidTabsPlugin.class); + registerPlugin(Utils.class); super.onCreate(savedInstanceState); + EdgeToEdge.enable(this); WebView webView = getBridge().getWebView(); webView.setOverScrollMode(WebView.OVER_SCROLL_NEVER); webView.getSettings().setUseWideViewPort(true); webView.getSettings().setLoadWithOverviewMode(true); + applyLogseqTheme(); + + // Let Compose host the WebView with system bar padding for safe areas and handle back. + ComposeHost.INSTANCE.renderWithSystemInsets(this, webView, () -> { + sendJsBack(webView); + return null; + }, () -> { + finish(); + return null; + }); + + routeChangeReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (!UILocal.ACTION_ROUTE_CHANGED.equals(intent.getAction())) return; + String stack = intent.getStringExtra("stack"); + String navigationType = intent.getStringExtra("navigationType"); + String path = intent.getStringExtra("path"); + navigationCoordinator.onRouteChange(stack, navigationType, path); + } + }; + IntentFilter filter = new IntentFilter(UILocal.ACTION_ROUTE_CHANGED); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + registerReceiver(routeChangeReceiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(routeChangeReceiver, filter); + } + // initNavigationBarBgColor(); new Timer().schedule(new TimerTask() { @@ -44,6 +87,58 @@ public class MainActivity extends BridgeActivity { }, 5000); } + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + applyLogseqTheme(); + dispatchSystemThemeToWeb(); + } + + private void applyLogseqTheme() { + LogseqTheme.INSTANCE.update(this); + LogseqThemeColors colors = LogseqTheme.INSTANCE.current(); + + int bg = colors.getBackground(); + boolean isDark = colors.isDark(); + + View content = findViewById(android.R.id.content); + if (content != null) { + content.setBackgroundColor(bg); + } + + WebView webView = getBridge() != null ? getBridge().getWebView() : null; + if (webView != null) { + webView.setBackgroundColor(bg); + } + + getWindow().getDecorView().setBackgroundColor(bg); + getWindow().setStatusBarColor(bg); + getWindow().setNavigationBarColor(bg); + + WindowInsetsControllerCompat controller = new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView()); + controller.setAppearanceLightStatusBars(!isDark); + controller.setAppearanceLightNavigationBars(!isDark); + + WebViewSnapshotManager.INSTANCE.setSnapshotBackgroundColor(bg); + } + + public void applyLogseqThemeNow() { + applyLogseqTheme(); + } + + private void dispatchSystemThemeToWeb() { + try { + if (bridge == null) return; + boolean isDark = LogseqTheme.INSTANCE.isDark(this); + String js = "window.dispatchEvent(new CustomEvent('logseq:native-system-theme-changed', { detail: { isDark: " + + (isDark ? "true" : "false") + + " } }));"; + bridge.eval(js, null); + } catch (Exception e) { + // ignore + } + } + public void initNavigationBarBgColor() { NavigationBarPlugin navigationBarPlugin = new NavigationBarPlugin(); JSObject data = new JSObject(); @@ -59,6 +154,20 @@ public class MainActivity extends BridgeActivity { super.onPause(); } + @Override + public void onBackPressed() { + Log.d("onBackPressed", "Debug"); + + WebView webView = getBridge().getWebView(); + if (webView != null) { + // Send "native back" into JS. JS will call your UILocal/route-change, + // which flows into ComposeHost.applyNavigation(...) and animates. + sendJsBack(webView); + } else { + // Fallback if for some reason there is no webview + super.onBackPressed(); + } + } @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); @@ -74,4 +183,21 @@ public class MainActivity extends BridgeActivity { }); } } + + @Override + public void onDestroy() { + if (routeChangeReceiver != null) { + unregisterReceiver(routeChangeReceiver); + routeChangeReceiver = null; + } + super.onDestroy(); + } + + private void sendJsBack(WebView webView) { + if (webView == null) return; + webView.post(() -> webView.evaluateJavascript( + "window.LogseqNative && window.LogseqNative.onNativePop && window.LogseqNative.onNativePop();", + null + )); + } } diff --git a/android/app/src/main/java/com/logseq/app/MaterialIconResolver.kt b/android/app/src/main/java/com/logseq/app/MaterialIconResolver.kt new file mode 100644 index 0000000000..a8a3ebd4b2 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/MaterialIconResolver.kt @@ -0,0 +1,85 @@ +package com.logseq.app + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmarks +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.CameraAlt +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.AddCircle +import androidx.compose.material.icons.outlined.DataArray +import androidx.compose.material.icons.outlined.KeyboardCommandKey +import androidx.compose.material.icons.outlined.ArrowBack +import androidx.compose.material.icons.outlined.ArrowForward +import androidx.compose.material.icons.outlined.BookmarkAdd +import androidx.compose.material.icons.outlined.CalendarToday +import androidx.compose.material.icons.outlined.CheckBox +import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Dashboard +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Equalizer +import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.GraphicEq +import androidx.compose.material.icons.outlined.KeyboardHide +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.LocalOffer +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.Redo +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.StarBorder +import androidx.compose.material.icons.outlined.Undo +import androidx.compose.ui.graphics.vector.ImageVector + +object MaterialIconResolver { + fun resolve(name: String?): ImageVector? { + val key = name + ?.trim() + ?.lowercase() + ?.replace("_", "-") + ?.replace("\\s+".toRegex(), "-") + ?.replace(".", "-") + ?: return null + + return when (key) { + "chevron-backward", "arrow-left", "back" -> Icons.Outlined.ArrowBack + "arrow-right" -> Icons.Outlined.ArrowForward + "arrow-uturn-backward" -> Icons.Outlined.Undo + "arrow-uturn-forward" -> Icons.Outlined.Redo + "calendar" -> Icons.Outlined.CalendarToday + "waveform", "audio", "equalizer" -> Icons.Outlined.GraphicEq + "ellipsis" -> Icons.Outlined.MoreVert + "star-fill" -> Icons.Filled.Star + "star" -> Icons.Outlined.StarBorder + "circle-fill" -> Icons.Filled.Circle + "plus", "add" -> Icons.Outlined.Add + "paperplane", "send" -> Icons.Filled.Send + "todo", "checkmark-square" -> Icons.Outlined.CheckBox + "number", "tag" -> Icons.Outlined.LocalOffer + "parentheses" -> Icons.Outlined.DataArray + "command", "slash" -> Icons.Outlined.KeyboardCommandKey + "camera" -> Icons.Outlined.CameraAlt + "keyboard-chevron-compact-down", "keyboard-hide" -> Icons.Outlined.KeyboardHide + "doc-on-doc", "copy" -> Icons.Outlined.ContentCopy + "trash", "delete" -> Icons.Outlined.Delete + "r-square", "bookmark-ref" -> Icons.Outlined.BookmarkAdd + "link" -> Icons.Outlined.Link + "xmark", "close" -> Icons.Filled.Close + "house", "home" -> Icons.Filled.Home + "app-background-dotted" -> Icons.Outlined.Dashboard + "tray", "add" -> Icons.Outlined.AddCircle + "square-stack-3d-down-right", "layers" -> Icons.Filled.Layers + "magnifyingglass", "search" -> Icons.Outlined.Search + "go-to", "goto" -> Icons.Outlined.Explore + "bookmark" -> Icons.Filled.Bookmarks + "sync" -> Icons.Outlined.Equalizer + else -> null + } + } +} diff --git a/android/app/src/main/java/com/logseq/app/NativeBottomSheetPlugin.kt b/android/app/src/main/java/com/logseq/app/NativeBottomSheetPlugin.kt new file mode 100644 index 0000000000..3d2eff64f5 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/NativeBottomSheetPlugin.kt @@ -0,0 +1,182 @@ +package com.logseq.app + +import android.graphics.Color +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog + +@CapacitorPlugin(name = "NativeBottomSheetPlugin") +class NativeBottomSheetPlugin : Plugin() { + private val snapshotTag = "bottom-sheet" + private val mainHandler = Handler(Looper.getMainLooper()) + private var dialog: BottomSheetDialog? = null + private var previousParent: ViewGroup? = null + private var previousIndex: Int = -1 + private var previousLayoutParams: ViewGroup.LayoutParams? = null + private var placeholder: View? = null + private var container: FrameLayout? = null + + @PluginMethod + fun present(call: PluginCall) { + val activity = activity ?: run { + call.reject("No activity") + return + } + val webView = bridge.webView ?: run { + call.reject("No webview") + return + } + + activity.runOnUiThread { + WebViewSnapshotManager.registerWindow(activity.window) + if (dialog != null) { + call.resolve() + return@runOnUiThread + } + + val ctx = activity + container = FrameLayout(ctx) + container!!.layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + val sheet = BottomSheetDialog(ctx) + sheet.setContentView(container!!) + + WebViewSnapshotManager.showSnapshot(snapshotTag, webView) + + // Move the WebView into the BottomSheet container + detachWebView(webView, ctx) + container!!.addView( + webView, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + + val behavior = sheet.behavior + val defaultHeight = call.getInt("defaultHeight", null) + val allowFullHeight = call.getBoolean("allowFullHeight") ?: true + if (defaultHeight != null) { + val peek = NativeUiUtils.dp(ctx, defaultHeight.toFloat()) + behavior.peekHeight = peek + behavior.state = BottomSheetBehavior.STATE_COLLAPSED + } else { + behavior.state = BottomSheetBehavior.STATE_COLLAPSED + } + behavior.skipCollapsed = !allowFullHeight + + sheet.setOnDismissListener { + notifyListeners("state", JSObject().put("dismissing", true)) + + // Forcefully detach WebView before restoring + try { + (webView.parent as? ViewGroup)?.removeView(webView) + container?.removeAllViews() + } catch (_: Exception) {} + + // Delay restoration slightly to let Android clean up window surfaces + mainHandler.post { + restoreWebView(webView) + webView.alpha = 0f + + webView.postDelayed({ + webView.alpha = 1f + WebViewSnapshotManager.clearSnapshot(snapshotTag) + notifyListeners( + "state", + JSObject() + .put("presented", false) + .put("dismissing", false) + ) + }, 120) + } + + dialog = null + container = null + } + + notifyListeners("state", JSObject().put("presenting", true)) + sheet.show() + notifyListeners("state", JSObject().put("presented", true)) + dialog = sheet + call.resolve() + } + } + + @PluginMethod + fun dismiss(call: PluginCall) { + activity?.runOnUiThread { + if (dialog == null) { + WebViewSnapshotManager.clearSnapshot(snapshotTag) + call.resolve() + return@runOnUiThread + } + + dialog?.dismiss() + call.resolve() + } ?: call.resolve() + } + + private fun detachWebView(webView: View, ctx: android.content.Context) { + val parent = webView.parent as? ViewGroup ?: return + previousParent = parent + previousIndex = parent.indexOfChild(webView) + previousLayoutParams = webView.layoutParams + + parent.removeView(webView) + placeholder = View(ctx).apply { + setBackgroundColor(LogseqTheme.current().background) + layoutParams = previousLayoutParams ?: ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + parent.addView(placeholder, previousIndex) + } + + private fun restoreWebView(webView: View) { + val parent = previousParent ?: return + val lp = previousLayoutParams ?: ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + // Fully detach from any container + (webView.parent as? ViewGroup)?.removeView(webView) + placeholder?.let { parent.removeView(it) } + placeholder = null + + // Reattach WebView + if (previousIndex in 0..parent.childCount) { + parent.addView(webView, previousIndex, lp) + } else { + parent.addView(webView, lp) + } + + // ✅ Force WebView to recreate its SurfaceView and redraw + webView.visibility = View.INVISIBLE + webView.post { + webView.visibility = View.VISIBLE + webView.requestLayout() + webView.invalidate() + webView.dispatchWindowVisibilityChanged(View.VISIBLE) + } + + previousParent = null + previousLayoutParams = null + previousIndex = -1 + container = null + } +} diff --git a/android/app/src/main/java/com/logseq/app/NativeEditorToolbarPlugin.kt b/android/app/src/main/java/com/logseq/app/NativeEditorToolbarPlugin.kt new file mode 100644 index 0000000000..921ee0e95d --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/NativeEditorToolbarPlugin.kt @@ -0,0 +1,263 @@ +package com.logseq.app + +import android.graphics.Color +import android.view.Gravity +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color as ComposeColor +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin + +@CapacitorPlugin(name = "NativeEditorToolbarPlugin") +class NativeEditorToolbarPlugin : Plugin() { + private var toolbarView: EditorToolbarView? = null + + @PluginMethod + fun present(call: PluginCall) { + val activity = activity ?: run { + call.reject("No activity") + return + } + + val actions = parseActions(call.getArray("actions")) + val trailing = call.getObject("trailingAction")?.let { EditorAction.from(it) } + val tintHex = call.getString("tintColor") + val bgHex = call.getString("backgroundColor") + + activity.runOnUiThread { + if (actions.isEmpty() && trailing == null) { + dismissInternal() + call.resolve() + return@runOnUiThread + } + + val view = toolbarView ?: EditorToolbarView(activity).also { v -> + v.onAction = { id -> + notifyListeners("action", JSObject().put("id", id)) + } + toolbarView = v + } + + view.bind(actions, trailing, tintHex, bgHex) + + val root = NativeUiUtils.contentRoot(activity) + if (view.parent !== root) { + NativeUiUtils.detachView(view) + val lp = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM + ).apply { + val margin = NativeUiUtils.dp(activity, 12f) + setMargins(margin, margin, margin, margin) + } + root.addView(view, lp) + } + + call.resolve() + } + } + + @PluginMethod + fun dismiss(call: PluginCall) { + activity?.runOnUiThread { + dismissInternal() + call.resolve() + } ?: call.resolve() + } + + private fun dismissInternal() { + val root = activity?.let { NativeUiUtils.contentRoot(it) } ?: return + toolbarView?.let { root.removeView(it) } + toolbarView = null + } + + private fun parseActions(array: JSArray?): List { + if (array == null) return emptyList() + val result = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + EditorAction.from(obj)?.let { result.add(it) } + } + return result + } +} + +data class EditorAction( + val id: String, + val title: String, + val systemIcon: String? +) { + companion object { + fun from(obj: org.json.JSONObject): EditorAction? { + val id = obj.optString("id", "") + if (id.isBlank()) return null + val title = obj.optString("title", id) + val icon = obj.optString("systemIcon", null) + return EditorAction(id, title, icon) + } + } +} + +private class EditorToolbarView(context: android.content.Context) : FrameLayout(context) { + var onAction: ((String) -> Unit)? = null + + private val composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + + private var actions: List = emptyList() + private var trailing: EditorAction? = null + private var tint: Int = defaultTint() + private var backgroundColor: Int = defaultBackground() + + init { + addView(composeView) + } + + fun bind( + actions: List, + trailing: EditorAction?, + tintHex: String?, + bgHex: String? + ) { + this.actions = actions + this.trailing = trailing + tint = NativeUiUtils.parseColor(tintHex, defaultTint()) + backgroundColor = NativeUiUtils.parseColor(bgHex, defaultBackground()) + render() + } + + private fun defaultTint(): Int = + if (LogseqTheme.current().isDark) Color.WHITE else Color.BLACK + + private fun defaultBackground(): Int = LogseqTheme.current().background + + private fun render() { + val onActionFn = onAction + val actionsSnapshot = actions + val trailingSnapshot = trailing + val tintColor = tint + val bgColor = backgroundColor + + composeView.setContent { + EditorToolbar( + actions = actionsSnapshot, + trailing = trailingSnapshot, + tint = ComposeColor(tintColor), + background = ComposeColor(bgColor), + onAction = { id -> onActionFn?.invoke(id) } + ) + } + } +} + +@Composable +private fun EditorToolbar( + actions: List, + trailing: EditorAction?, + tint: ComposeColor, + background: ComposeColor, + onAction: (String) -> Unit +) { + Surface( + color = background, + shadowElevation = 6.dp, + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .navigationBarsPadding() + .imePadding() // Lift toolbar above system nav/IME when the keyboard opens + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + actions.forEach { action -> + ToolbarButton(action, tint, onAction) + } + } + + trailing?.let { trailingAction -> + if (actions.isNotEmpty()) { + Spacer(modifier = Modifier.width(8.dp)) + } + ToolbarButton(trailingAction, tint, onAction) + } + } + } +} + +@Composable +private fun ToolbarButton( + action: EditorAction, + tint: ComposeColor, + onAction: (String) -> Unit +) { + val icon = remember(action.systemIcon) { MaterialIconResolver.resolve(action.systemIcon) } + val contentTint = remember(tint) { tint.copy(alpha = 0.8f) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .defaultMinSize(minWidth = 44.dp) + .clickable { onAction(action.id) } + .padding(horizontal = 10.dp, vertical = 8.dp) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = action.title.ifBlank { action.id }, + tint = contentTint, + modifier = Modifier + .defaultMinSize(minWidth = 20.dp) + .padding(end = 2.dp) + ) + } else { + Text( + text = action.title, + color = contentTint, + fontSize = 14.sp + ) + } + } +} diff --git a/android/app/src/main/java/com/logseq/app/NativeSelectionActionBarPlugin.kt b/android/app/src/main/java/com/logseq/app/NativeSelectionActionBarPlugin.kt new file mode 100644 index 0000000000..474819dc22 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/NativeSelectionActionBarPlugin.kt @@ -0,0 +1,279 @@ +package com.logseq.app + +import android.graphics.Color +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color as ComposeColor +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.doOnNextLayout +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import kotlin.math.max + +@CapacitorPlugin(name = "NativeSelectionActionBarPlugin") +class NativeSelectionActionBarPlugin : Plugin() { + private var barView: SelectionActionBarView? = null + + @PluginMethod + fun present(call: PluginCall) { + val activity = activity ?: run { + call.reject("No activity") + return + } + + val actionsArray = call.getArray("actions") + val actions = parseActions(actionsArray) + val tintHex = call.getString("tintColor") + val bgHex = call.getString("backgroundColor") + + activity.runOnUiThread { + if (actions.isEmpty()) { + dismissInternal() + call.resolve() + return@runOnUiThread + } + + val root = NativeUiUtils.contentRoot(activity) + val view = barView ?: SelectionActionBarView(activity).also { v -> + v.onAction = { id -> + notifyListeners("action", JSObject().put("id", id)) + } + barView = v + } + + view.bind(actions, tintHex, bgHex) + + if (view.parent !== root) { + NativeUiUtils.detachView(view) + + val lp = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM + ).apply { + val margin = NativeUiUtils.dp(activity, 12f) + val bottomOffset = computeBottomOffset(activity, root) + // top / left / right: margin + // bottom: margin + bottom nav height + system/IME inset + setMargins(margin, margin, margin, margin + bottomOffset) + } + + root.addView(view, lp) + } + + call.resolve() + } + } + + @PluginMethod + fun dismiss(call: PluginCall) { + activity?.runOnUiThread { + dismissInternal() + call.resolve() + } ?: call.resolve() + } + + private fun dismissInternal() { + val activity = activity ?: return + val root = NativeUiUtils.contentRoot(activity) + + barView?.let { root.removeView(it) } + barView = null + } + + private fun parseActions(array: JSArray?): List { + if (array == null) return emptyList() + val result = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + SelectionAction.from(obj)?.let { result.add(it) } + } + return result + } + + /** + * Compute how far we must lift the bar from the bottom so that it + * sits above: + * - the BottomNavigationView created by LiquidTabsPlugin + * - system nav / gesture bar + * - IME (when showing) + */ + private fun computeBottomOffset(activity: android.app.Activity, root: ViewGroup): Int { + val insets = ViewCompat.getRootWindowInsets(root) + val systemBarsBottom = insets?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom ?: 0 + val imeBottom = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0 + + // Find the bottom nav created by LiquidTabsPlugin (must have this ID set there) + val bottomNav = activity.findViewById(R.id.liquid_tabs_bottom_nav) + // Fallback height if nav not measured yet + val navHeight = bottomNav?.height ?: NativeUiUtils.dp(activity, 56f) + + val insetBottom = max(systemBarsBottom, imeBottom) + return navHeight + insetBottom + } +} + +data class SelectionAction( + val id: String, + val title: String, + val systemIcon: String? +) { + companion object { + fun from(obj: org.json.JSONObject): SelectionAction? { + val id = obj.optString("id", "") + if (id.isBlank()) return null + val title = obj.optString("title", id) + val icon = obj.optString("systemIcon", null) + return SelectionAction(id, title, icon) + } + } +} + +private class SelectionActionBarView(context: android.content.Context) : FrameLayout(context) { + var onAction: ((String) -> Unit)? = null + private val composeView: ComposeView + + init { + composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + addView(composeView) + } + + fun bind(actions: List, tintHex: String?, bgHex: String?) { + val defaultTint = if (LogseqTheme.current().isDark) Color.WHITE else Color.BLACK + val tint = ComposeColor(NativeUiUtils.parseColor(tintHex, defaultTint)) + val backgroundColor = ComposeColor(NativeUiUtils.parseColor(bgHex, LogseqTheme.current().background)) + val onActionFn = onAction + + composeView.setContent { + SelectionActionBar(actions, tint, backgroundColor) { id -> + onActionFn?.invoke(id) + } + } + composeView.doOnNextLayout { requestLayout() } + } +} + +@Composable +private fun SelectionActionBar( + actions: List, + tint: ComposeColor, + background: ComposeColor, + onAction: (String) -> Unit +) { + val (mainActions, trailingAction) = remember(actions) { + val primary = if (actions.size > 1) actions.dropLast(1) else emptyList() + Pair(primary, actions.lastOrNull()) + } + val scrollState = rememberScrollState() + + Surface( + color = background, + shadowElevation = 6.dp, + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (mainActions.isNotEmpty()) { + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(scrollState), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + mainActions.forEach { action -> + SelectionActionButton(action, tint, onAction) + } + } + } + + trailingAction?.let { action -> + if (mainActions.isNotEmpty()) { + Spacer(modifier = Modifier.width(10.dp)) + Divider( + modifier = Modifier + .height(28.dp) + .width(1.dp), + color = tint.copy(alpha = 0.15f) + ) + Spacer(modifier = Modifier.width(10.dp)) + } + SelectionActionButton(action, tint, onAction) + } + } + } +} + +@Composable +private fun SelectionActionButton( + action: SelectionAction, + tint: ComposeColor, + onAction: (String) -> Unit +) { + val icon = remember(action.systemIcon) { MaterialIconResolver.resolve(action.systemIcon) } + val contentTint = remember(tint) { tint.copy(alpha = 0.8f) } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .defaultMinSize(minWidth = 56.dp) + .clickable { onAction(action.id) } + .padding(horizontal = 6.dp, vertical = 8.dp) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = action.title.ifBlank { action.id }, + tint = contentTint, + modifier = Modifier.size(22.dp) + ) + } + Text( + text = action.title, + color = contentTint, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + } +} diff --git a/android/app/src/main/java/com/logseq/app/NativeTopBarPlugin.kt b/android/app/src/main/java/com/logseq/app/NativeTopBarPlugin.kt new file mode 100644 index 0000000000..321ba6455e --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/NativeTopBarPlugin.kt @@ -0,0 +1,331 @@ +package com.logseq.app + +import android.graphics.Color +import android.os.Build +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color as ComposeColor +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.doOnNextLayout +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin + +@CapacitorPlugin(name = "NativeTopBarPlugin") +class NativeTopBarPlugin : Plugin() { + private var topBarView: NativeTopBarView? = null + private var originalWebViewPaddingTop: Int? = null + + @PluginMethod + fun configure(call: PluginCall) { + val activity = activity ?: run { + call.reject("No activity") + return + } + + activity.runOnUiThread { + val hidden = call.getBoolean("hidden") ?: false + val title = call.getString("title") ?: "" + val leftButtons = parseButtons(call.getArray("leftButtons")) + val rightButtons = parseButtons(call.getArray("rightButtons")) + val titleClickable = call.getBoolean("titleClickable") ?: false + val tintHex = call.getString("tintColor") + val tintColorOverride = + tintHex?.takeIf { it.isNotBlank() }?.let { NativeUiUtils.parseColor(it, LogseqTheme.current().tint) } + + val webView = bridge.webView + + if (hidden) { + removeBar() + restorePadding(webView) + call.resolve() + return@runOnUiThread + } + + val bar = topBarView ?: NativeTopBarView(activity).also { view -> + view.onTap = { id -> + notifyListeners("buttonTapped", JSObject().put("id", id)) + } + attachBar(view) + topBarView = view + } + + bar.bind(title, titleClickable, leftButtons, rightButtons, tintColorOverride) + bar.post { + adjustWebViewPadding(webView, bar.height) + } + call.resolve() + } + } + + private fun attachBar(bar: NativeTopBarView) { + val root = NativeUiUtils.contentRoot(activity) + if (bar.parent !== root) { + NativeUiUtils.detachView(bar) + val lp = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + root.addView(bar, lp) + } + } + + private fun removeBar() { + val root = activity?.let { NativeUiUtils.contentRoot(it) } ?: return + topBarView?.let { view -> + root.removeView(view) + } + topBarView = null + } + + private fun statusBarInset(webView: android.webkit.WebView?): Int { + if (webView == null) return 0 + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + webView.rootWindowInsets?.getInsets(android.view.WindowInsets.Type.statusBars())?.top ?: 0 + } else { + @Suppress("DEPRECATION") + webView.rootWindowInsets?.stableInsetTop ?: 0 + } + } + + private fun adjustWebViewPadding(webView: android.webkit.WebView?, barHeight: Int) { + if (webView == null) return + if (originalWebViewPaddingTop == null) { + originalWebViewPaddingTop = webView.paddingTop + } + val insetTop = statusBarInset(webView) + val target = (barHeight - insetTop).coerceAtLeast(0) + .takeIf { it > 0 } ?: NativeUiUtils.dp(webView.context, 56f) + webView.setPadding( + webView.paddingLeft, + target, + webView.paddingRight, + webView.paddingBottom + ) + } + + private fun restorePadding(webView: android.webkit.WebView?) { + if (webView == null) return + val original = originalWebViewPaddingTop + if (original != null) { + webView.setPadding(webView.paddingLeft, original, webView.paddingRight, webView.paddingBottom) + } + } + + private fun parseButtons(array: JSArray?): List { + if (array == null) return emptyList() + val result = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + val id = obj.optString("id", "") + if (id.isBlank()) continue + val systemIcon = obj.optString("systemIcon", "") + val title = obj.optString("title", id) + val tintHex = obj.optString("tintColor", obj.optString("color", "")) + val iconSize = if (id == "sync") { + "small" + } else { + "medium" + } + + val size = obj.optString("size", iconSize) + result.add( + ButtonSpec( + id = id, + title = if (title.isNotBlank()) title else id, + systemIcon = systemIcon.takeIf { it.isNotBlank() }, + tint = tintHex, + size = size + ) + ) + } + return result + } +} + +data class ButtonSpec( + val id: String, + val title: String, + val systemIcon: String?, + val tint: String?, + val size: String +) + +private class NativeTopBarView(context: android.content.Context) : FrameLayout(context) { + var onTap: ((String) -> Unit)? = null + private val composeView = ComposeView(context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + } + + init { + addView(composeView) + } + + fun bind( + title: String, + titleClickable: Boolean, + leftButtons: List, + rightButtons: List, + tintColorOverride: Int? + ) { + val onTapFn = onTap + composeView.setContent { + TopBarContent( + title = title, + titleClickable = titleClickable, + leftButtons = leftButtons, + rightButtons = rightButtons, + tintOverride = tintColorOverride, + onTap = { id -> onTapFn?.invoke(id) } + ) + } + doOnNextLayout { + requestLayout() + } + } +} + +@Composable +private fun TopBarContent( + title: String, + titleClickable: Boolean, + leftButtons: List, + rightButtons: List, + tintOverride: Int?, + onTap: (String) -> Unit +) { + val theme by LogseqTheme.colors.collectAsState() + val background = ComposeColor(theme.background) + val tint = tintOverride?.let { ComposeColor(it) } ?: ComposeColor(theme.tint) + val contentTint = tint.copy(alpha = 0.8f) + + Surface( + color = background, + shadowElevation = 4.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + leftButtons.forEachIndexed { index, button -> + if (index > 0) { + Spacer(modifier = Modifier.width(8.dp)) + } + TopBarButton(button, contentTint, onTap) + } + } + + Text( + text = title, + color = contentTint, + fontSize = 17.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(horizontal = 6.dp) + .let { mod -> + if (titleClickable) { + mod.clickable { onTap("title") } + } else { + mod + } + } + ) + + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + rightButtons.forEachIndexed { index, button -> + if (index > 0) { + Spacer(modifier = Modifier.width(8.dp)) + } + TopBarButton(button, contentTint, onTap) + } + } + } + } +} + +@Composable +private fun TopBarButton( + spec: ButtonSpec, + fallbackTint: ComposeColor, + onTap: (String) -> Unit +) { + val icon = remember(spec.systemIcon) { MaterialIconResolver.resolve(spec.systemIcon) } + val baseTint = remember(spec.tint, fallbackTint) { + spec.tint?.let { ComposeColor(NativeUiUtils.parseColor(it, fallbackTint.toArgb())) } ?: fallbackTint + } + val tint = remember(baseTint) { baseTint.copy(alpha = 0.8f) } + val fontSize = when (spec.size.lowercase()) { + "small" -> 13.sp + "large" -> 17.sp + else -> 15.sp + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { onTap(spec.id) } + .padding(horizontal = 8.dp, vertical = 6.dp) + ) { + if (icon != null) { + val iconSize = if (spec.size.lowercase() == "small") { + 12.dp + } else { + 22.dp + } + Icon( + imageVector = icon, + contentDescription = spec.title.ifBlank { spec.id }, + tint = tint, + modifier = Modifier.size(iconSize) + ) + } else { + Text( + text = spec.title, + color = tint, + fontSize = fontSize, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} diff --git a/android/app/src/main/java/com/logseq/app/NativeUiUtils.kt b/android/app/src/main/java/com/logseq/app/NativeUiUtils.kt new file mode 100644 index 0000000000..559f2886fb --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/NativeUiUtils.kt @@ -0,0 +1,31 @@ +package com.logseq.app + +import android.app.Activity +import android.content.Context +import android.graphics.Color +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout + +object NativeUiUtils { + fun dp(context: Context, value: Float): Int = + (value * context.resources.displayMetrics.density).toInt() + + fun parseColor(hex: String?, defaultColor: Int): Int { + if (hex.isNullOrBlank()) return defaultColor + return try { + Color.parseColor(hex) + } catch (_: IllegalArgumentException) { + defaultColor + } + } + + fun contentRoot(activity: Activity): FrameLayout = + activity.findViewById(android.R.id.content) + + fun detachView(view: View?): ViewGroup? { + val parent = view?.parent as? ViewGroup ?: return null + parent.removeView(view) + return parent + } +} diff --git a/android/app/src/main/java/com/logseq/app/NavigationCoordinator.kt b/android/app/src/main/java/com/logseq/app/NavigationCoordinator.kt new file mode 100644 index 0000000000..c49faaab07 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/NavigationCoordinator.kt @@ -0,0 +1,74 @@ +package com.logseq.app + +class NavigationCoordinator { + private val primaryStack = "home" + private val stackPaths: MutableMap> = mutableMapOf( + primaryStack to mutableListOf("/") + ) + var activeStackId: String = primaryStack + private set + + private fun defaultPath(stack: String): String = + if (stack == primaryStack) "/" else "/__stack__/$stack" + + private fun normalizedPath(raw: String?): String = + raw?.takeIf { it.isNotBlank() } ?: "/" + + fun onRouteChange(stack: String?, navigationType: String?, path: String?) { + val stackId = stack?.takeIf { it.isNotBlank() } ?: primaryStack + val navType = navigationType?.lowercase() ?: "push" + val resolvedPath = normalizedPath(path) + + val paths = stackPaths.getOrPut(stackId) { mutableListOf(defaultPath(stackId)) } + + when (navType) { + "reset" -> { + paths.clear() + paths.add(resolvedPath) + } + + "replace" -> { + if (paths.isEmpty()) { + paths.add(resolvedPath) + } else { + paths[paths.lastIndex] = resolvedPath + } + } + + "pop" -> { + if (paths.size > 1) { + paths.removeAt(paths.lastIndex) + } + } + + else -> { // push (default) + if (paths.isEmpty()) { + paths.add(resolvedPath) + } else if (paths.last() != resolvedPath) { + paths.add(resolvedPath) + } + } + } + + // Special case: reset home stack to root when path is "/" + if (stackId == primaryStack && resolvedPath == "/") { + paths.clear() + paths.add("/") + } + + activeStackId = stackId + stackPaths[stackId] = paths + } + + fun canPop(): Boolean { + val paths = stackPaths[activeStackId] ?: return false + return paths.size > 1 + } + + fun pop(): String? { + val paths = stackPaths[activeStackId] ?: return null + if (paths.size <= 1) return null + paths.removeAt(paths.lastIndex) + return paths.lastOrNull() + } +} diff --git a/android/app/src/main/java/com/logseq/app/UILocal.kt b/android/app/src/main/java/com/logseq/app/UILocal.kt index 0da340c4b2..37923da853 100644 --- a/android/app/src/main/java/com/logseq/app/UILocal.kt +++ b/android/app/src/main/java/com/logseq/app/UILocal.kt @@ -1,13 +1,12 @@ package com.logseq.app -import android.app.AlertDialog import android.app.DatePickerDialog -import android.os.Build +import android.content.Intent import android.view.View import android.view.ViewGroup import android.widget.DatePicker import android.widget.FrameLayout -import androidx.annotation.RequiresApi +import android.widget.Toast import com.getcapacitor.JSObject import com.getcapacitor.Plugin import com.getcapacitor.PluginCall @@ -19,14 +18,17 @@ import java.util.Locale @CapacitorPlugin(name = "UILocal") class UILocal : Plugin() { + private var toast: Toast? = null + companion object { + const val ACTION_ROUTE_CHANGED = "com.logseq.app.ROUTE_DID_CHANGE" + } - @RequiresApi(Build.VERSION_CODES.O) @PluginMethod fun showDatePicker(call: PluginCall) { val defaultDate = call.getString("defaultDate") val calendar = Calendar.getInstance() - if (defaultDate.isNullOrEmpty()) { + if (!defaultDate.isNullOrEmpty()) { try { val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) val date = defaultDate?.let { sdf.parse(it) } @@ -85,4 +87,57 @@ class UILocal : Plugin() { call.reject("Error showing date picker", e) } } -} \ No newline at end of file + + @PluginMethod + fun alert(call: PluginCall) { + val message = call.getString("title") ?: call.getString("message") + val duration = if ((call.getDouble("duration") ?: 0.0) > 3.5) Toast.LENGTH_LONG else Toast.LENGTH_SHORT + val ctx = context ?: run { + call.reject("No context") + return + } + if (message.isNullOrBlank()) { + call.reject("title or message is required") + return + } + toast?.cancel() + toast = Toast.makeText(ctx, message, duration).also { it.show() } + call.resolve() + } + + @PluginMethod + fun hideAlert(call: PluginCall) { + toast?.cancel() + toast = null + call.resolve() + } + + @PluginMethod + fun routeDidChange(call: PluginCall) { + val navigationType = call.getString("navigationType") ?: "push" + val push = call.getBoolean("push") ?: (navigationType == "push") + val path = call.getString("path") ?: "/" + val stack = call.getString("stack") ?: "home" + + // Drive Compose Nav for native animations/back handling. + ComposeHost.applyNavigation(navigationType, path) + + val ctx = context + if (ctx != null) { + val intent = Intent(ACTION_ROUTE_CHANGED).apply { + putExtra("navigationType", navigationType) + putExtra("push", push) + putExtra("stack", stack) + putExtra("path", path) + } + ctx.sendBroadcast(intent) + } + + call.resolve() + } + + @PluginMethod + fun transcribeAudio2Text(call: PluginCall) { + call.reject("transcription not supported on Android") + } +} diff --git a/android/app/src/main/java/com/logseq/app/Utils.kt b/android/app/src/main/java/com/logseq/app/Utils.kt new file mode 100644 index 0000000000..e46d81c3b7 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/Utils.kt @@ -0,0 +1,38 @@ +package com.logseq.app + +import androidx.appcompat.app.AppCompatDelegate +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin + +@CapacitorPlugin(name = "Utils") +class Utils : Plugin() { + @PluginMethod + fun setInterfaceStyle(call: PluginCall) { + val mode = (call.getString("mode") ?: "system").lowercase() + val system = call.getBoolean("system") ?: (mode == "system") + + val nightMode = + if (system || mode == "system") { + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } else if (mode == "dark") { + AppCompatDelegate.MODE_NIGHT_YES + } else { + AppCompatDelegate.MODE_NIGHT_NO + } + + val activity = activity + if (activity == null) { + call.reject("No activity") + return + } + + activity.runOnUiThread { + AppCompatDelegate.setDefaultNightMode(nightMode) + (activity as? MainActivity)?.applyLogseqThemeNow() + call.resolve() + } + } +} + diff --git a/android/app/src/main/java/com/logseq/app/WebViewSnapshotManager.kt b/android/app/src/main/java/com/logseq/app/WebViewSnapshotManager.kt new file mode 100644 index 0000000000..e3e8fc6709 --- /dev/null +++ b/android/app/src/main/java/com/logseq/app/WebViewSnapshotManager.kt @@ -0,0 +1,185 @@ +package com.logseq.app + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Rect +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.View +import android.view.Window +import android.widget.FrameLayout +import android.widget.ImageView +import java.lang.ref.WeakReference + +/** + * Utility to capture lightweight WebView snapshots and show them in the overlay container. + * Used to keep the background stable while the shared WebView is being reparented + * (navigation transitions, bottom sheet presentation/dismiss). + */ +object WebViewSnapshotManager { + private var overlayRef: WeakReference? = null + private var windowRef: WeakReference? = null + private val snapshotRefs: MutableMap> = mutableMapOf() + private val containerRefs: MutableMap> = mutableMapOf() + private var snapshotBackgroundColor: Int = LogseqTheme.current().background + private val mainHandler = Handler(Looper.getMainLooper()) + + fun setSnapshotBackgroundColor(color: Int) { + snapshotBackgroundColor = color + overlayRef?.get()?.setBackgroundColor(Color.TRANSPARENT) + } + + fun registerWindow(window: Window?) { + windowRef = window?.let { WeakReference(it) } + } + + fun registerOverlay(overlay: FrameLayout?) { + overlayRef = overlay?.let { WeakReference(it) } + } + + fun showSnapshot(tag: String, webView: View): View? { + val overlay = ensureOverlay(webView) ?: return null + clearSnapshot(tag) + overlay.visibility = View.VISIBLE + + val snapshotView = makeSnapshotView(webView) + + overlay.addView( + snapshotView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + overlay.bringChildToFront(snapshotView) + + snapshotRefs[tag] = WeakReference(snapshotView) + containerRefs[tag] = WeakReference(overlay) + + return snapshotView + } + + fun clearSnapshot(tag: String) { + val view = snapshotRefs.remove(tag)?.get() + val container = containerRefs.remove(tag)?.get() + if (view != null && container != null) { + container.removeView(view) + if (container.childCount == 0) { + container.visibility = View.GONE + } + } + } + + fun clearAll() { + snapshotRefs.keys.toList().forEach { clearSnapshot(it) } + } + + private fun ensureOverlay(webView: View): FrameLayout? { + overlayRef?.get()?.let { return it } + val root = webView.rootView + val overlay = root.findViewById(R.id.webview_overlay_container) + if (overlay != null) { + overlayRef = WeakReference(overlay) + } + return overlay + } + + private fun makeSnapshotView(webView: View): View { + val width = webView.width + val height = webView.height + if (width <= 0 || height <= 0) { + return View(webView.context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(snapshotBackgroundColor) + isClickable = false + isFocusable = false + } + } + + // Limit bitmap size to avoid OOM (e.g., max 4096x4096 or 16MB) + val MAX_BITMAP_DIMENSION = 4096 + val MAX_BITMAP_SIZE = 16 * 1024 * 1024 // 16MB + val safeWidth = width.coerceAtMost(MAX_BITMAP_DIMENSION) + val safeHeight = height.coerceAtMost(MAX_BITMAP_DIMENSION) + val estimatedSize = safeWidth * safeHeight * 4 + if (estimatedSize > MAX_BITMAP_SIZE) { + // Fallback: show plain color view if too large + return View(webView.context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(snapshotBackgroundColor) + isClickable = false + isFocusable = false + } + } + + val bitmap = try { + Bitmap.createBitmap(safeWidth, safeHeight, Bitmap.Config.ARGB_8888) + } catch (e: OutOfMemoryError) { + // Fallback: show plain color view if OOM + return View(webView.context).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(snapshotBackgroundColor) + isClickable = false + isFocusable = false + } + } + // Fast fallback: capture via View#draw (can be imperfect for WebView on some devices). + try { + val canvas = Canvas(bitmap) + canvas.drawColor(snapshotBackgroundColor) + // If we had to scale down, scale the canvas + if (safeWidth != width || safeHeight != height) { + val scaleX = safeWidth.toFloat() / width + val scaleY = safeHeight.toFloat() / height + canvas.scale(scaleX, scaleY) + } + webView.draw(canvas) + } catch (_: Exception) { + // Keep bitmap with background color only. + } + + val imageView = ImageView(webView.context).apply { + setImageBitmap(bitmap) + scaleType = ImageView.ScaleType.FIT_XY + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(snapshotBackgroundColor) + isClickable = false + isFocusable = false + } + + // Higher-fidelity capture: PixelCopy from the Window buffer. + val window = windowRef?.get() + if (window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + val loc = IntArray(2) + webView.getLocationInWindow(loc) + val rect = Rect(loc[0], loc[1], loc[0] + width, loc[1] + height) + + PixelCopy.request(window, rect, bitmap, { result -> + if (result == PixelCopy.SUCCESS) { + imageView.invalidate() + } + }, mainHandler) + } catch (_: Exception) { + // Ignore; fallback bitmap already set. + } + } + + return imageView + } +} diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index e321a4ad48..052c0a7b04 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,6 +1,8 @@ #0f262e - #f7f7f7 + + #037DBA + #6097c7 #0f262e diff --git a/android/app/src/main/res/values/ids.xml b/android/app/src/main/res/values/ids.xml new file mode 100644 index 0000000000..14b2b64e10 --- /dev/null +++ b/android/app/src/main/res/values/ids.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 6d3897ca24..39e1540361 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -14,6 +14,7 @@ true @null true + @color/colorAccent @color/colorPrimary @color/colorPrimary diff --git a/android/build.gradle b/android/build.gradle index 77a2715b02..b2ec27893c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,9 +1,11 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - + ext { - kotlin_version = '2.1.21' + kotlin_version = '2.0.21' + composeCompilerVersion = '2.0.1' + androidxActivityVersion = '1.9.2' } repositories { google() @@ -14,6 +16,7 @@ buildscript { classpath 'com.android.tools.build:gradle:8.7.2' classpath 'com.google.gms:google-services:4.4.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$composeCompilerVersion" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -33,4 +36,3 @@ allprojects { task clean(type: Delete) { delete rootProject.buildDir } - diff --git a/android/variables.gradle b/android/variables.gradle index b319e0e7d1..b08801614b 100644 --- a/android/variables.gradle +++ b/android/variables.gradle @@ -6,6 +6,7 @@ ext { androidxAppCompatVersion = '1.7.0' androidxCoordinatorLayoutVersion = '1.2.0' androidxCoreVersion = '1.15.0' + androidxNavigationVersion = '2.9.6' androidxFragmentVersion = '1.8.4' junitVersion = '4.13.2' androidxJunitVersion = '1.2.1' @@ -13,4 +14,7 @@ ext { cordovaAndroidVersion = '10.1.1' coreSplashScreenVersion = '1.0.1' androidxWebkitVersion = '1.12.1' + materialVersion = '1.12.0' + composeBomVersion = '2024.09.02' + composeCompilerVersion = '1.7.0' } diff --git a/src/main/frontend/mobile/util.cljs b/src/main/frontend/mobile/util.cljs index 10640100e6..b11f0a4cec 100644 --- a/src/main/frontend/mobile/util.cljs +++ b/src/main/frontend/mobile/util.cljs @@ -22,19 +22,27 @@ (defn convert-file-src [path-str] (.convertFileSrc Capacitor path-str)) +(defn plugin-available? + "Check if a native plugin is available from Capacitor.Plugins." + [name] + (boolean (aget (.-Plugins js/Capacitor) name))) + (defonce folder-picker (registerPlugin "FolderPicker")) (defonce ui-local (registerPlugin "UILocal")) -(defonce native-top-bar nil) -(defonce native-bottom-sheet nil) -(defonce native-editor-toolbar nil) -(defonce native-selection-action-bar nil) -(defonce ios-utils nil) -(when (native-ios?) - (set! native-top-bar (registerPlugin "NativeTopBarPlugin")) - (set! native-bottom-sheet (registerPlugin "NativeBottomSheetPlugin")) - (set! native-editor-toolbar (registerPlugin "NativeEditorToolbarPlugin")) - (set! native-selection-action-bar (registerPlugin "NativeSelectionActionBarPlugin")) - (set! ios-utils (registerPlugin "Utils"))) +(defonce native-top-bar (when (and (native-platform?) + (plugin-available? "NativeTopBarPlugin")) + (registerPlugin "NativeTopBarPlugin"))) +(defonce native-bottom-sheet (when (and (native-platform?) + (plugin-available? "NativeBottomSheetPlugin")) + (registerPlugin "NativeBottomSheetPlugin"))) +(defonce native-editor-toolbar (when (and (native-platform?) + (plugin-available? "NativeEditorToolbarPlugin")) + (registerPlugin "NativeEditorToolbarPlugin"))) +(defonce native-selection-action-bar (when (and (native-platform?) + (plugin-available? "NativeSelectionActionBarPlugin")) + (registerPlugin "NativeSelectionActionBarPlugin"))) +(defonce ios-utils (when (native-ios?) (registerPlugin "Utils"))) +(defonce android-utils (when (native-android?) (registerPlugin "Utils"))) (defonce ios-content-size-listener nil) @@ -71,6 +79,21 @@ (.setInterfaceStyle ^js ios-utils (clj->js {:mode mode :system system?}))))) +(defn set-android-interface-style! + [mode system?] + (when (native-android?) + (p/do! + (.setInterfaceStyle ^js android-utils (clj->js {:mode mode + :system system?}))))) + +(defn set-native-interface-style! + "Sync native light/dark/system appearance with Logseq theme mode." + [mode system?] + (cond + (native-ios?) (set-ios-interface-style! mode system?) + (native-android?) (set-android-interface-style! mode system?) + :else nil)) + (defn get-idevice-model [] (when (native-ios?) @@ -158,11 +181,11 @@ accessibility (assoc :accessibility accessibility))] (cond (not title) (p/rejected (js/Error. "title is required")) - (native-ios?) (.alert ^js ui-local (clj->js payload)) + (native-platform?) (.alert ^js ui-local (clj->js payload)) :else (p/resolved nil)))) (defn hide-alert [] - (if (native-ios?) + (if (native-platform?) (.hideAlert ^js ui-local) (p/resolved nil))) diff --git a/src/main/frontend/state.cljs b/src/main/frontend/state.cljs index 1244ecc657..76481cb3f1 100644 --- a/src/main/frontend/state.cljs +++ b/src/main/frontend/state.cljs @@ -1430,8 +1430,8 @@ Similar to re-frame subscriptions" (if (= mode "light") (util/set-theme-light) (util/set-theme-dark))) - (when (mobile-util/native-ios?) - (mobile-util/set-ios-interface-style! mode system-theme?)) + (when (mobile-util/native-platform?) + (mobile-util/set-native-interface-style! mode system-theme?)) (set-state! :ui/theme mode) (storage/set :ui/theme mode))) @@ -1475,8 +1475,8 @@ Similar to re-frame subscriptions" [] (let [mode (or (storage/get :ui/theme) "light") system-theme? (storage/get :ui/system-theme?)] - (when (mobile-util/native-ios?) - (mobile-util/set-ios-interface-style! mode system-theme?)) + (when (mobile-util/native-platform?) + (mobile-util/set-native-interface-style! mode system-theme?)) (when (and (not system-theme?) (mobile-util/native-platform?)) (if (= mode "light") diff --git a/src/main/mobile/bottom_tabs.cljs b/src/main/mobile/bottom_tabs.cljs index 7926c9460e..5b04cb105d 100644 --- a/src/main/mobile/bottom_tabs.cljs +++ b/src/main/mobile/bottom_tabs.cljs @@ -1,17 +1,18 @@ (ns mobile.bottom-tabs - "iOS bottom tabs" + "Native bottom tabs" (:require [cljs-bean.core :as bean] [clojure.string :as string] [frontend.handler.editor :as editor-handler] [frontend.handler.route :as route-handler] + [frontend.mobile.util :as mobile-util] [frontend.state :as state] [frontend.util :as util] + [mobile.navigation :as mobile-nav] [mobile.search :as mobile-search] [mobile.state :as mobile-state] [promesa.core :as p])) -;; Capacitor plugin instance: -;; Make sure the plugin is registered as `LiquidTabs` on the native side. +;; Capacitor plugin instance (nil if native side hasn't shipped it yet). (def ^js liquid-tabs (.. js/Capacitor -Plugins -LiquidTabsPlugin)) @@ -22,17 +23,19 @@ [{:id \"home\" :title \"Home\" :system-image \"house\" :role \"normal\"} {:id \"search\" :title \"Search\" :system-image \"magnifyingglass\" :role \"search\"}]" [tabs] - ;; Returns the underlying JS Promise from Capacitor - (.configureTabs - liquid-tabs - (bean/->js {:tabs tabs}))) + (when liquid-tabs + ;; Returns the underlying JS Promise from Capacitor + (.configureTabs + liquid-tabs + (bean/->js {:tabs tabs})))) (defn select! "Programmatically select a tab by id. Returns a JS Promise." [id] - (.selectTab - liquid-tabs - #js {:id id})) + (when liquid-tabs + (.selectTab + liquid-tabs + #js {:id id}))) (defn update-native-search-results! "Send native search result list to the iOS plugin." @@ -43,17 +46,17 @@ (defn add-tab-selected-listener! "Listen to native tab selection. - `f` receives the tab id string. + `f` receives the tab id string and a boolean indicating reselect. Returns the Capacitor listener handle; call `(.remove handle)` to unsubscribe." [f] (when (and (util/capacitor?) liquid-tabs) (.addListener liquid-tabs "tabSelected" - (fn [data] - ;; data is like { id: string } + (fn [^js data] + ;; data is like { id: string, reselected?: boolean } (when-let [id (.-id data)] - (f id)))))) + (f id (boolean (.-reselected data)))))))) (defn add-search-listener! "Listen to native search query changes from the SwiftUI search tab. @@ -61,54 +64,71 @@ `f` receives a query string. Returns the Capacitor listener handle; call `(.remove handle)` to unsubscribe." [f] - (.addListener - liquid-tabs - "searchChanged" - (fn [data] - ;; data is like { query: string } - (f (.-query data))))) + (when (and (util/capacitor?) liquid-tabs) + (.addListener + liquid-tabs + "searchChanged" + (fn [data] + ;; data is like { query: string } + (f (.-query data)))))) (defn add-search-result-item-listener! [] - (.addListener - liquid-tabs - "openSearchResultBlock" - (fn [data] - (when-let [id (.-id data)] - (when-not (string/blank? id) - (route-handler/redirect-to-page! id {:push false})))))) + (when (and (util/capacitor?) liquid-tabs) + (.addListener + liquid-tabs + "openSearchResultBlock" + (fn [data] + (when-let [id (.-id data)] + (when-not (string/blank? id) + (route-handler/redirect-to-page! id {:push (mobile-util/native-android?)}))))))) (defn add-keyboard-hack-listener! "Listen for Backspace or Enter while the invisible keyboard field is focused." [] - (.addListener - liquid-tabs - "keyboardHackKey" - (fn [data] - ;; data is like { key: string } - (when-let [k (.-key data)] - (case k - "backspace" - (editor-handler/delete-block-when-zero-pos! nil) - "enter" - (when-let [input (state/get-input)] - (let [value (.-value input)] - (when (string/blank? value) - (editor-handler/keydown-new-block-handler nil)))) - nil))))) + (when (and (util/capacitor?) liquid-tabs) + (.addListener + liquid-tabs + "keyboardHackKey" + (fn [data] + ;; data is like { key: string } + (when-let [k (.-key data)] + (case k + "backspace" + (editor-handler/delete-block-when-zero-pos! nil) + "enter" + (when-let [input (state/get-input)] + (let [value (.-value input)] + (when (string/blank? value) + (editor-handler/keydown-new-block-handler nil)))) + nil)))))) (defonce add-tab-listeners! - (do - (add-tab-selected-listener! - (fn [tab] - (mobile-state/set-tab! tab) + (when (and (util/capacitor?) liquid-tabs) + (let [*current-tab (atom nil)] + (add-tab-selected-listener! + (fn [tab reselected?] + (cond + reselected? + (do + (mobile-nav/pop-to-root! tab) + (mobile-state/set-tab! tab) + (when (= "home" tab) + (util/scroll-to-top false))) - (when (= "home" tab) - (util/scroll-to-top false)))) + (not= @*current-tab tab) + (do + (reset! *current-tab tab) + (mobile-state/set-tab! tab) + (when (= "home" tab) + (util/scroll-to-top false)))))) + + (add-watch mobile-state/*tab ::select-tab + (fn [_ _ _old new] + (when (and new (not= @*current-tab new)) + (reset! *current-tab new) + (select! new))))) - (add-watch mobile-state/*tab ::select-tab - (fn [_ _ _old new] - (when new (select! new)))) (add-search-listener! (fn [q] ;; wire up search handler @@ -122,19 +142,25 @@ (defn configure [] (configure-tabs - [{:id "home" - :title "Home" - :systemImage "house" - :role "normal"} - {:id "graphs" - :title "Graphs" - :systemImage "app.background.dotted" - :role "normal"} - {:id "capture" - :title "Capture" - :systemImage "tray" - :role "normal"} - {:id "go to" - :title "Go To" - :systemImage "square.stack.3d.down.right" - :role "normal"}])) + (cond-> + [{:id "home" + :title "Home" + :systemImage "house" + :role "normal"} + {:id "graphs" + :title "Graphs" + :systemImage "app.background.dotted" + :role "normal"} + {:id "capture" + :title "Capture" + :systemImage "tray" + :role "normal"} + {:id "go to" + :title "Go To" + :systemImage "square.stack.3d.down.right" + :role "normal"}] + (mobile-util/native-android?) + (conj {:id "search" + :title "Search" + :systemImage "search" + :role "search"})))) diff --git a/src/main/mobile/components/app.cljs b/src/main/mobile/components/app.cljs index 310d5b424f..a0d66ed246 100644 --- a/src/main/mobile/components/app.cljs +++ b/src/main/mobile/components/app.cljs @@ -53,7 +53,13 @@ (hooks/use-effect! (fn [] (state/sync-system-theme!) - (ui/setup-system-theme-effect!)) + (ui/setup-system-theme-effect!) + (let [handler (fn [^js e] + (when (:ui/system-theme? @state/state) + (let [is-dark? (boolean (some-> e .-detail .-isDark))] + (state/set-theme-mode! (if is-dark? "dark" "light") true))))] + (.addEventListener js/window "logseq:native-system-theme-changed" handler) + #(.removeEventListener js/window "logseq:native-system-theme-changed" handler))) []) (hooks/use-effect! #(let [^js doc js/document.documentElement @@ -89,7 +95,9 @@ {:did-mount (fn [state] (p/do! (editor-handler/quick-add-ensure-new-block-exists!) - (editor-handler/quick-add-open-last-block!)) + (when (mobile-util/native-ios?) + ;; FIXME: android doesn't open keyboard automatically + (editor-handler/quick-add-open-last-block!))) state)} [] (quick-add/quick-add)) @@ -118,7 +126,7 @@ ;; Both are absolutely positioned and stacked; we toggle visibility. [:div.w-full.relative ;; Journals scroll container (keep-alive) - [:div#app-main-home.pl-3.pr-2.absolute.inset-0 + [:div#app-main-home.pl-4.pr-3.absolute.inset-0 {:class (when-not home? "invisible pointer-events-none")} (home)] @@ -134,7 +142,7 @@ (use-theme-effects! current-repo theme) (hooks/use-effect! (fn [] - (when (mobile-util/native-ios?) + (when (mobile-util/native-platform?) (bottom-tabs/configure)) (when-let [element (util/app-scroll-container-node)] (common-handler/listen-to-scroll! element))) diff --git a/src/main/mobile/components/app.css b/src/main/mobile/components/app.css index 37e5dd2abc..d47a8ce49f 100644 --- a/src/main/mobile/components/app.css +++ b/src/main/mobile/components/app.css @@ -23,6 +23,12 @@ html.is-ios { padding-bottom: env(safe-area-inset-bottom); } +html.is-native-android { + #app-container-wrapper { + padding-top: 56px; + padding-bottom: 0; + } +} html.is-native-ios body, html.is-native-ios textarea, html.is-native-ios input, diff --git a/src/main/mobile/components/editor_toolbar.cljs b/src/main/mobile/components/editor_toolbar.cljs index 570ed64aef..dfa0a85865 100644 --- a/src/main/mobile/components/editor_toolbar.cljs +++ b/src/main/mobile/components/editor_toolbar.cljs @@ -1,7 +1,6 @@ (ns mobile.components.editor-toolbar "Mobile editor toolbar" - (:require [frontend.colors :as colors] - [frontend.commands :as commands] + (:require [frontend.commands :as commands] [frontend.handler.editor :as editor-handler] [frontend.handler.history :as history] [frontend.mobile.camera :as mobile-camera] @@ -179,12 +178,12 @@ native-actions (mapv action->native main) trailing-native (some-> trailing action->native) plugin ^js mobile-util/native-editor-toolbar - should-show? (and show? (mobile-util/native-ios?) (some? plugin))] + should-show? (and show? (mobile-util/native-platform?) (some? plugin))] (set! (.-current handlers-ref) (action-handlers main trailing)) (hooks/use-effect! (fn [] - (when (and (mobile-util/native-ios?) plugin) + (when (and (mobile-util/native-platform?) plugin) (let [listener (.addListener plugin "action" (fn [^js e] (when-let [id (.-id e)] @@ -199,12 +198,11 @@ (hooks/use-effect! (fn [] - (when (mobile-util/native-ios?) + (when (mobile-util/native-platform?) (when should-show? (.present plugin (clj->js {:actions native-actions - :trailingAction trailing-native - :tintColor (colors/get-accent-color)})))) - #(when (and (mobile-util/native-ios?) should-show?) + :trailingAction trailing-native})))) + #(when (and (mobile-util/native-platform?) should-show?) (.dismiss plugin))) [should-show? native-actions trailing-native]) @@ -217,5 +215,5 @@ show? (and (not code-block?) editing?) actions (toolbar-actions)] - (when (mobile-util/native-ios?) + (when (mobile-util/native-platform?) (native-toolbar show? actions)))) diff --git a/src/main/mobile/components/header.cljs b/src/main/mobile/components/header.cljs index f066bf0bbe..a2b3eb5944 100644 --- a/src/main/mobile/components/header.cljs +++ b/src/main/mobile/components/header.cljs @@ -108,11 +108,13 @@ {:default-height false})) (defn- register-native-top-bar-events! [] - (when (and (mobile-util/native-ios?) + (when (and (mobile-util/native-platform?) + mobile-util/native-top-bar (not @native-top-bar-listener?)) (.addListener ^js mobile-util/native-top-bar "buttonTapped" (fn [^js e] (case (.-id e) + "back" (js/history.back) "title" (open-graph-switch!) "calendar" (open-journal-calendar!) "capture" (do @@ -142,8 +144,9 @@ (defn- configure-native-top-bar! [repo {:keys [tab title route-name route-view sync-color favorited?]}] - (when (mobile-util/native-ios?) - (let [hidden? (= tab "search") + (when (and (mobile-util/native-platform?) + mobile-util/native-top-bar) + (let [hidden? (and (mobile-util/native-ios?) (= tab "search")) rtc-indicator? (and repo (ldb/get-graph-rtc-uuid (db/get-db)) (user-handler/logged-in?)) @@ -153,6 +156,7 @@ (assoc :title title)) page? (= route-name :page) left-buttons (cond + page? [{:id "back" :systemIcon "chevron.backward"}] (and (= tab "home") (nil? route-view)) [(conj {:id "calendar" :systemIcon "calendar"})] (and (= tab "capture") (nil? route-view)) @@ -178,6 +182,9 @@ [{:id "capture" :systemIcon "paperplane"}] :else nil) + [left-buttons right-buttons] (if (mobile-util/native-android?) + [(reverse left-buttons) (reverse right-buttons)] + [left-buttons right-buttons]) header (cond-> base left-buttons (assoc :leftButtons left-buttons) right-buttons (assoc :rightButtons right-buttons) @@ -208,7 +215,8 @@ "#CA8A04")] (hooks/use-effect! (fn [] - (when (mobile-util/native-ios?) + (when (and (mobile-util/native-platform?) + mobile-util/native-top-bar) (register-native-top-bar-events!) (p/let [block (when (= route-name :page) (let [id (get-in route-match [:parameters :path :name])] diff --git a/src/main/mobile/components/popup.cljs b/src/main/mobile/components/popup.cljs index e00529407a..2db292e668 100644 --- a/src/main/mobile/components/popup.cljs +++ b/src/main/mobile/components/popup.cljs @@ -41,8 +41,7 @@ (defn- handle-native-sheet-state! [^js data] - (let [;; presenting? (.-presenting data) - dismissing? (.-dismissing data)] + (let [dismissing? (.-dismissing data)] (cond dismissing? (when (some? @mobile-state/*popup-data) @@ -50,17 +49,16 @@ (state/pub-event! [:mobile/clear-edit]) (mobile-state/set-popup! nil) (reset! *last-popup-data nil) - (when (mobile-util/native-ios?) - (let [plugin ^js mobile-util/native-editor-toolbar] - (.dismiss plugin))))) + (when-let [plugin ^js mobile-util/native-editor-toolbar] + (.dismiss plugin)))) :else nil))) (defonce native-sheet-listener - (when (mobile-util/native-ios?) - (when-let [^js plugin mobile-util/native-bottom-sheet] - (.addListener plugin "state" handle-native-sheet-state!)))) + (when-let [^js plugin (when (mobile-util/native-platform?) + mobile-util/native-bottom-sheet)] + (.addListener plugin "state" handle-native-sheet-state!))) (defn- wrap-calc-commands-popup-side [pos opts] @@ -98,7 +96,7 @@ :else (when content-fn (reset! *last-popup? true) - (when (mobile-util/native-ios?) + (when-let [_plugin ^js mobile-util/native-bottom-sheet] (let [data {:open? true :content-fn content-fn :opts opts}] diff --git a/src/main/mobile/components/selection_toolbar.cljs b/src/main/mobile/components/selection_toolbar.cljs index 9c3199d342..0e1b8c2c28 100644 --- a/src/main/mobile/components/selection_toolbar.cljs +++ b/src/main/mobile/components/selection_toolbar.cljs @@ -10,7 +10,8 @@ (defn- dismiss-action-bar! [] - (.dismiss ^js mobile-util/native-selection-action-bar)) + (when-let [plugin ^js mobile-util/native-selection-action-bar] + (.dismiss plugin))) (defn close-selection-bar! [] @@ -81,7 +82,7 @@ (hooks/use-effect! (fn [] - (when (and (mobile-util/native-ios?) + (when (and (mobile-util/native-platform?) mobile-util/native-selection-action-bar) (let [listener (.addListener ^js mobile-util/native-selection-action-bar "action" diff --git a/src/main/mobile/init.cljs b/src/main/mobile/init.cljs index 30e0811bde..03af7734a0 100644 --- a/src/main/mobile/init.cljs +++ b/src/main/mobile/init.cljs @@ -3,7 +3,6 @@ (:require ["@capacitor/app" :refer [^js App]] ["@capacitor/keyboard" :refer [^js Keyboard]] ["@capacitor/network" :refer [^js Network]] - ["@capgo/capacitor-navigation-bar" :refer [^js NavigationBar]] [clojure.string :as string] [frontend.handler.editor :as editor-handler] [frontend.mobile.flows :as mobile-flows] @@ -46,8 +45,6 @@ (defn- android-init! "Initialize Android-specified event listeners" [] - (js/setTimeout - #(.setNavigationBarColor NavigationBar #js {:color "transparent"}) 128) (.addListener App "backButton" (fn [] (when (false? diff --git a/src/main/mobile/navigation.cljs b/src/main/mobile/navigation.cljs index 9252b4a587..f2cf615127 100644 --- a/src/main/mobile/navigation.cljs +++ b/src/main/mobile/navigation.cljs @@ -1,5 +1,5 @@ (ns mobile.navigation - "Native navigation bridge for mobile (iOS)." + "Native navigation bridge for mobile." (:require [clojure.string :as string] [frontend.handler.route :as route-handler] [frontend.mobile.util :as mobile-util] @@ -186,7 +186,7 @@ path (if (string/blank? path) "/" path)] (set-current-stack! stack) (remember-route! stack navigation-type route path route-match) - (when (and (mobile-util/native-ios?) + (when (and (mobile-util/native-platform?) mobile-util/ui-local) (let [payload (cond-> {:navigationType navigation-type :push push? @@ -238,6 +238,12 @@ (let [stack (current-stack) {:keys [history]} (get @stack-history stack) history (vec history)] + ;; back to search root + (when (and + (mobile-util/native-android?) + (= stack "search") + (= (count history) 2)) + (.showSearchUiNative ^js (.. js/Capacitor -Plugins -LiquidTabsPlugin))) (when (>= (count history) 1) (let [root-history? (= (count history) 1) new-history (if root-history? @@ -259,6 +265,33 @@ (route-handler/set-route-match! route-match))))) +(defn pop-to-root! + "Pop current or given stack back to its root entry and notify navigation." + ([] (pop-to-root! (current-stack))) + ([stack] + (when stack + (let [{:keys [history]} (get @stack-history stack) + root (or (first history) (stack-defaults stack)) + {:keys [route route-match path]} root + route-match (or route-match (:route-match (stack-defaults stack))) + path (or path (current-path)) + route (or route {:to (get-in route-match [:data :name]) + :path-params (get-in route-match [:parameters :path]) + :query-params (get-in route-match [:parameters :query])})] + (swap! stack-history assoc stack {:history [root]}) + (set-current-stack! stack) + ;; Use original replace-state to avoid recording a push intent. + (orig-replace-state (get-in route-match [:data :name]) + (get-in route-match [:parameters :path]) + (get-in route-match [:parameters :query])) + (route-handler/set-route-match! route-match) + (notify-route-change! + {:route route + :route-match route-match + :path path + :stack stack + :push false}))))) + (defn ^:export install-native-bridge! [] (set! (.-LogseqNative js/window)