Android native UX (#12254)

feat: android native UX
This commit is contained in:
Tienson Qin
2025-12-12 16:10:56 +00:00
committed by GitHub
parent 068dbf266b
commit f910fcfea8
32 changed files with 2791 additions and 131 deletions

View File

@@ -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"

View File

@@ -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">

View File

@@ -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<NavigationEvent>(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<FrameLayout>(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<ComposeView>("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<NavigationEvent>,
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<FrameLayout>(R.id.webview_container)
val overlayContainer =
root.findViewById<FrameLayout>(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<NavigationEvent>,
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")
}
}
}
}
}

View File

@@ -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<List<TabSpec>>(emptyList())
private var currentTabId by mutableStateOf<String?>(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<TabSpec>,
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<TabSpec> {
if (array == null) return emptyList()
val result = mutableListOf<TabSpec>()
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<SearchResult> {
if (array == null) return emptyList()
val result = mutableListOf<SearchResult>()
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?
)

View File

@@ -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<LogseqThemeColors> = _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,
)
}
}

View File

@@ -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
));
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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<EditorAction> {
if (array == null) return emptyList()
val result = mutableListOf<EditorAction>()
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<EditorAction> = emptyList()
private var trailing: EditorAction? = null
private var tint: Int = defaultTint()
private var backgroundColor: Int = defaultBackground()
init {
addView(composeView)
}
fun bind(
actions: List<EditorAction>,
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<EditorAction>,
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
)
}
}
}

View File

@@ -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<SelectionAction> {
if (array == null) return emptyList()
val result = mutableListOf<SelectionAction>()
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<View>(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<SelectionAction>, 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<SelectionAction>,
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
)
}
}

View File

@@ -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<ButtonSpec> {
if (array == null) return emptyList()
val result = mutableListOf<ButtonSpec>()
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<ButtonSpec>,
rightButtons: List<ButtonSpec>,
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<ButtonSpec>,
rightButtons: List<ButtonSpec>,
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
)
}
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,74 @@
package com.logseq.app
class NavigationCoordinator {
private val primaryStack = "home"
private val stackPaths: MutableMap<String, MutableList<String>> = 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()
}
}

View File

@@ -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)
}
}
}
@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")
}
}

View File

@@ -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()
}
}
}

View File

@@ -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<FrameLayout>? = null
private var windowRef: WeakReference<Window>? = null
private val snapshotRefs: MutableMap<String, WeakReference<View>> = mutableMapOf()
private val containerRefs: MutableMap<String, WeakReference<FrameLayout>> = 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<FrameLayout>(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
}
}

View File

@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="logoPrimary">#0f262e</color>
<color name="colorPrimary">#f7f7f7</color>
<!-- Main brand color (HSL 200 97% 37%) -->
<color name="colorPrimary">#037DBA</color>
<color name="colorAccent">#6097c7</color>
<color name="colorPrimaryDark">#0f262e</color>
</resources>

View File

@@ -0,0 +1,5 @@
<resources>
<item name="webview_container" type="id"/>
<item name="webview_overlay_container" type="id"/>
<item name="liquid_tabs_bottom_nav" type="id"/>
</resources>

View File

@@ -14,6 +14,7 @@
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<item name="android:windowIsTranslucent">true</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:navigationBarColor">@color/colorPrimary</item>
<item name="android:statusBarColor">@color/colorPrimary</item>
</style>

View File

@@ -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
}

View File

@@ -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'
}

View File

@@ -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)))

View File

@@ -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")

View File

@@ -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"}))))

View File

@@ -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)))

View File

@@ -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,

View File

@@ -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))))

View File

@@ -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])]

View File

@@ -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}]

View File

@@ -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"

View File

@@ -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?

View File

@@ -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)