From c8c9b361afec899deeec15e00eb7c05a56085e28 Mon Sep 17 00:00:00 2001 From: MaxDac <25101798+MaxDac@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:23:51 +0000 Subject: [PATCH] feat: DrawerSection hook in SyncHooks, rendered in FolderDrawer Co-Authored-By: Claude Sonnet 5.5 --- app-shared/build.gradle.kts | 2 + .../ui/browser/FolderDrawerSectionTest.kt | 79 +++++++++++++++++++ .../github/maxdac/marknotes/MainActivity.kt | 3 +- .../github/maxdac/marknotes/sync/SyncHooks.kt | 8 ++ .../marknotes/ui/browser/BrowserScreen.kt | 19 ++++- 5 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 app-shared/src/androidTest/kotlin/io/github/maxdac/marknotes/ui/browser/FolderDrawerSectionTest.kt diff --git a/app-shared/build.gradle.kts b/app-shared/build.gradle.kts index eb7a030..191c16a 100644 --- a/app-shared/build.gradle.kts +++ b/app-shared/build.gradle.kts @@ -90,6 +90,8 @@ dependencies { testImplementation(testFixtures(project(":core"))) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) } // The shared fixtures (the shared spec) are not part of this public repository, see :core. diff --git a/app-shared/src/androidTest/kotlin/io/github/maxdac/marknotes/ui/browser/FolderDrawerSectionTest.kt b/app-shared/src/androidTest/kotlin/io/github/maxdac/marknotes/ui/browser/FolderDrawerSectionTest.kt new file mode 100644 index 0000000..b7a5b1f --- /dev/null +++ b/app-shared/src/androidTest/kotlin/io/github/maxdac/marknotes/ui/browser/FolderDrawerSectionTest.kt @@ -0,0 +1,79 @@ +package io.github.maxdac.marknotes.ui.browser + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.github.maxdac.marknotes.sync.FolderRemovalNotice +import io.github.maxdac.marknotes.sync.SyncHooks +import io.github.maxdac.marknotes.sync.SyncUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Inert hooks; [DrawerSection] is left at its default. */ +private open class InertHooks : SyncHooks { + override val available = false + override val state: StateFlow = MutableStateFlow(SyncUiState()) + override val providerNames = "" + override val aboutNotices = emptyList() + + override fun syncIfConnected() = Unit + + override fun syncNow() = Unit + + override fun acknowledgeFolderNotice(notice: FolderRemovalNotice) = Unit + + override fun onActivityResumed() = Unit + + override fun watchNetwork(activity: ComponentActivity) = Unit + + override fun onForeground(foreground: Boolean) = Unit + + override fun onActivityFinishing() = Unit + + override fun updateReconnectPolicy(enabled: Boolean, unlocked: Boolean) = Unit + + override suspend fun deleteAllLocalData() = Unit +} + +@RunWith(AndroidJUnit4::class) +class FolderDrawerSectionTest { + @get:Rule + val rule = createAndroidComposeRule() + + private fun show(hooks: SyncHooks) = rule.setContent { + val navController = rememberNavController() + FolderDrawer(listOf("a"), current = "", inSemantics = true, drawerSection = { + with(hooks) { DrawerSection(navController) } + }, onSelect = {}) + } + + /** The default [SyncHooks.DrawerSection] (what the libre app uses) renders nothing in the drawer. */ + @Test + fun defaultHookRendersNothing() { + show(InertHooks()) + rule.onNodeWithText("All notes").assertIsDisplayed() + rule.onNodeWithText("Fake section").assertDoesNotExist() + } + + @Test + fun hookSectionRendersInDrawer() { + show(object : InertHooks() { + @Composable + override fun ColumnScope.DrawerSection(navController: NavController) { + Text("Fake section") + } + }) + rule.onNodeWithText("All notes").assertIsDisplayed() + rule.onNodeWithText("Fake section").assertIsDisplayed() + } +} diff --git a/app-shared/src/main/kotlin/io/github/maxdac/marknotes/MainActivity.kt b/app-shared/src/main/kotlin/io/github/maxdac/marknotes/MainActivity.kt index 28cfba8..6aa25e2 100644 --- a/app-shared/src/main/kotlin/io/github/maxdac/marknotes/MainActivity.kt +++ b/app-shared/src/main/kotlin/io/github/maxdac/marknotes/MainActivity.kt @@ -309,7 +309,8 @@ private fun MarkNotesNavHost( onOpenNote = { navController.navigate(Routes.editor(it)) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) }, onOpenConflicts = { navController.navigate(Routes.CONFLICTS) }, - onBack = { navController.navigateUp() } + onBack = { navController.navigateUp() }, + drawerSection = { with(container.syncHooks) { DrawerSection(navController) } } ) } composable(Routes.EDITOR, arguments = pathArg) { entry -> diff --git a/app-shared/src/main/kotlin/io/github/maxdac/marknotes/sync/SyncHooks.kt b/app-shared/src/main/kotlin/io/github/maxdac/marknotes/sync/SyncHooks.kt index 77245be..5441ff3 100644 --- a/app-shared/src/main/kotlin/io/github/maxdac/marknotes/sync/SyncHooks.kt +++ b/app-shared/src/main/kotlin/io/github/maxdac/marknotes/sync/SyncHooks.kt @@ -124,6 +124,14 @@ interface SyncHooks { onLocalDataDeleted: () -> Unit ) { } + + /** + * An extra block of the left drawer, shown after the notes tree (inside the drawer's column). The libre app and + * [NoSyncHooks] show nothing; an app can add its own section, which may navigate with [navController]. + */ + @Composable + fun ColumnScope.DrawerSection(navController: NavController) { + } } /** diff --git a/app-shared/src/main/kotlin/io/github/maxdac/marknotes/ui/browser/BrowserScreen.kt b/app-shared/src/main/kotlin/io/github/maxdac/marknotes/ui/browser/BrowserScreen.kt index 8552eb8..9eec184 100644 --- a/app-shared/src/main/kotlin/io/github/maxdac/marknotes/ui/browser/BrowserScreen.kt +++ b/app-shared/src/main/kotlin/io/github/maxdac/marknotes/ui/browser/BrowserScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -116,7 +117,9 @@ fun BrowserScreen( onOpenNote: (String) -> Unit, onOpenSettings: () -> Unit, onOpenConflicts: () -> Unit, - onBack: () -> Unit + onBack: () -> Unit, + /** Extra block after the folder tree in the drawer; see [SyncHooks.DrawerSection]. */ + drawerSection: @Composable ColumnScope.() -> Unit = {} ) { val container = LocalContext.current.appContainer val vm: BrowserViewModel = viewModel { BrowserViewModel(container, folder) } @@ -136,7 +139,12 @@ fun BrowserScreen( drawerContent = { // A closed drawer stays composed; hide its items from semantics so they don't duplicate the list. val showTree = drawerState.isOpen || drawerState.isAnimationRunning - FolderDrawer(sortedFolders, current = folder, inSemantics = showTree) { target -> + FolderDrawer( + sortedFolders, + current = folder, + inSemantics = showTree, + drawerSection = drawerSection + ) { target -> scope.launch { drawerState.close() } if (target != folder) onJumpToFolder(target) } @@ -441,10 +449,11 @@ private fun BrowserDialogs( /** Folder tree; folders are collapsed except the ancestors of [current]. */ @Composable -private fun FolderDrawer( +internal fun FolderDrawer( folders: List, current: String, inSemantics: Boolean, + drawerSection: @Composable ColumnScope.() -> Unit = {}, onSelect: (String) -> Unit ) { var expanded by rememberSaveable(current) { @@ -457,7 +466,8 @@ private fun FolderDrawer( } ModalDrawerSheet(modifier = if (inSemantics) Modifier else Modifier.clearAndSetSemantics {}) { Text("Folders", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(16.dp)) - LazyColumn { + // fill = false: the tree keeps its natural height (unchanged without a section) and scrolls above the section. + LazyColumn(Modifier.weight(1f, fill = false)) { item { NavigationDrawerItem( label = { Text("All notes") }, @@ -490,6 +500,7 @@ private fun FolderDrawer( ) } } + drawerSection() } }