The top-level Find tab container: a segmented button row (Search Map Graph / Files) that delegates content to four sub-screens, each backed by its own ViewModel. This is the thinnest part of the Find feature cluster at 178 lines — it owns no ViewModel itself, existing purely as routing infrastructure.
Introduction
FindScreen is the container composable rendered by the Find tab in the bottom navigation bar. It defines the FindMode enum (four variants each carrying a label and icon), renders a SingleChoiceSegmentedButtonRow to switch between modes, and delegates content rendering to one of four sub-screens:
SearchScreenContent→ delegates toSearchScreen(the fully self-contained search composable)LocationMapScreen→ delegates to the OSM-based map composableGraphScreen→ delegates to the node-graph composableFilesScreenContent→ delegates toFilesScreenwithembedded = true
Two ViewModels (searchViewModel, mapViewModel) are hoisted to the ArcologyApp level in MainActivity and passed in as parameters because they are shared with other screens: the editor triggers mapViewModel.refresh() on save, and search results persist across tab switches. Two more (graphViewModel, filesViewModel) are resolved locally via koinViewModel() because they exist only within the Find tab and have no external consumers.
The container also wires cross-mode interactions: search result context menus can "Center on Graph" or "Pin to Graph", which switches the active mode to GRAPH and passes data to =GraphViewModel='s pinned-nodes state via collectAsState(). Similarly, FilesScreenContent resolves the root node for a selected file via RoamRepository before navigating.
Design Decisions
**Why hoist search/map ViewModels but not graph/files?
searchViewModel and mapViewModel are instantiated in ArcologyApp (the composable in MainActivity.kt) and passed down to FindScreen as parameters. This is because both ViewModels serve consumers outside the Find tab:
searchViewModel.refresh()is called in aLaunchedEffectwatching capture state — when a new note is saved via the Capture tab, search results reindex automatically.mapViewModel.refresh()is called on the same lifecycle, keeping location markers up to date after edits.searchViewModel.searchForTag()is called when a tag chip in the editor is tapped, navigating to Find tab with the tag pre-filtered.
graphViewModel and filesViewModel have no external consumers and are resolved inline via koinViewModel(). This keeps the ViewModel scope tied to the Find tab's composable lifecycle — they are destroyed when the tab is no longer visible.
**Why a segmented button row over separate tabs?
The five-tab bottom navigation (Agenda, Find, Quiz, Capture, Settings) already consumes significant screen real estate. Splitting Search, Map, Graph, and Files into separate bottom tabs would push the tab count to 8, which is unwieldy on mobile. The segmented button row provides sub-navigation within a single tab, keeping the bottom bar compact while preserving one-tap access to each find mode. All four modes are conceptually variants of "find content" — they answer the same question (where is this thing?) through different lenses.
**Why an enum with ordinal-based saveable state?
The selected mode is stored as an Int ordinal via rememberSaveable { mutableIntStateOf(...) }. Using ordinal rather than the enum itself avoids serialization issues with rememberSaveable (which requires Saver support for custom types). The FindMode.entries list provides stable iteration order (guaranteed by Kotlin enum declaration order), so ordinal-based lookup is reliable. The state survives process death because rememberSaveable ties into SavedStateHandle.
**Why cross-mode wiring in the container?
Search results expose a context menu with actions that span multiple modes: "Center on Graph" and "Pin to Graph". These actions set =GraphViewModel='s focus node or pinned set, then switch selectedModeOrdinal to GRAPH. The pinnedNodeIds flow is collected at the container level (not inside SearchScreen) because it needs to feed pin state indicators into search result rows. This cross-mode wiring is the container's main responsibility — without it, each sub-screen would be a walled garden with no interaction between modes.
**Why level-0 heading resolution in Files mode?
When a file is selected in FilesScreen, the container queries RoamRepository.getNodesByFile() and picks the =level == 0= node (the file-level heading). If no level-0 node exists, it falls back to the first node in the file. This is a pragmatic choice: file-level nodes are the conventional entry point for a file's content in the org-roam graph. The query runs in a coroutine scope tied to the composable lifecycle.
FindMode Enum
The FindMode enum defines the four primary content-finding modes of the app. Each variant carries:
label— a human-readable display string ("Search", "Map", "Graph", "Files")icon— a Material Icon vector (Search,Map,Hub,Folder)
The enum is declared at file scope (not inside FindScreen) so sub-screen org files can reference it in their type signatures without a nested import. The icon for GRAPH uses Icons.Default.Hub rather than a hypothetical Graph icon, because Material Icons 3 does not include a general-purpose graph icon — the hub/network metaphor is the closest visual match.
enum class FindMode(val label: String, val icon: ImageVector) {
SEARCH("Search", Icons.Default.Search),
MAP("Map", Icons.Default.Map),
GRAPH("Graph", Icons.Default.Hub),
FILES("Files", Icons.Default.Folder)
}FindScreen Composable
The top-level container composable. It receives searchViewModel and mapViewModel as parameters (hoisted from ArcologyApp), resolves graphViewModel and filesViewModel via Koin, and manages:
Mode selection state (
selectedModeOrdinal— anIntsaved inSavedStateHandle)Pinned node IDs stream (collected from
graphViewModel.pinnedNodeIds)A
SearchResultActionscallback bundle constructed from pinned state and mode-switching logic
The SearchResultActions instance is wrapped in remember(pinnedNodeIds) to avoid recomposition on every pinned-node change — it only rebuilds when the pinned set changes, which is infrequent (user explicitly pins/unpins nodes).
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FindScreen(
searchViewModel: SearchViewModel,
mapViewModel: MapViewModel,
onNodeSelected: (nodeId: String) -> Unit
) {
var selectedModeOrdinal by rememberSaveable { mutableIntStateOf(FindMode.SEARCH.ordinal) }
val selectedMode = FindMode.entries[selectedModeOrdinal]
val filesViewModel: FilesViewModel = koinViewModel()
val graphViewModel: GraphViewModel = koinViewModel()
val repository: RoamRepository = koinInject()
val scope = rememberCoroutineScope()
val context = LocalContext.current
Column(modifier = Modifier.fillMaxSize()) {
// Segmented button row
FindModeSelector(
selectedMode = selectedMode,
onModeSelected = { selectedModeOrdinal = it.ordinal },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
// Pinned nodes state for search result indicators
val pinnedNodeIds by graphViewModel.pinnedNodeIds.collectAsState()
// Search result actions for context menu
val searchResultActions = remember(pinnedNodeIds) {
SearchResultActions(
onOpen = onNodeSelected,
onCenterOnGraph = { nodeId ->
graphViewModel.setFocusNode(nodeId)
selectedModeOrdinal = FindMode.GRAPH.ordinal
},
onPin = { nodeId -> graphViewModel.pinNode(context, nodeId) },
onUnpin = { nodeId -> graphViewModel.unpinNode(context, nodeId) },
isPinned = { nodeId -> nodeId in pinnedNodeIds }
)
}
// Content based on selected mode
Box(modifier = Modifier.fillMaxSize()) {
when (selectedMode) {
FindMode.SEARCH -> {
SearchScreenContent(
viewModel = searchViewModel,
onNodeSelected = onNodeSelected,
resultActions = searchResultActions
)
}
FindMode.MAP -> {
LocationMapScreen(
viewModel = mapViewModel,
onNodeSelected = onNodeSelected
)
}
FindMode.GRAPH -> {
GraphScreen(
viewModel = graphViewModel,
onNodeSelected = onNodeSelected
)
}
FindMode.FILES -> {
FilesScreenContent(
viewModel = filesViewModel,
onFileSelected = { fileUri ->
// Look up the root node (level 0) for this file and navigate to it
scope.launch {
val nodes = repository.getNodesByFile(fileUri)
val rootNode = nodes.firstOrNull { it.level == 0 }
?: nodes.firstOrNull()
rootNode?.let { onNodeSelected(it.id) }
}
}
)
}
}
}
}
}FindModeSelector
A private composable rendering the SingleChoiceSegmentedButtonRow with one SegmentedButton per FindMode entry. Each button displays the mode's icon and label, and uses SegmentedButtonDefaults.itemShape() for proper corner radius handling (first and last buttons get rounded outer corners, middle buttons are flat-sided).
The active mode uses SegmentedButtonDefaults.Icon with the active flag for the Material3 active-state styling (tinted background, primary-color icon). The inactive state renders the icon directly without tinting.
@Composable
private fun FindModeSelector(
selectedMode: FindMode,
onModeSelected: (FindMode) -> Unit,
modifier: Modifier = Modifier
) {
SingleChoiceSegmentedButtonRow(modifier = modifier) {
FindMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = selectedMode == mode,
onClick = { onModeSelected(mode) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = FindMode.entries.size
),
icon = {
SegmentedButtonDefaults.Icon(active = selectedMode == mode) {
Icon(
imageVector = mode.icon,
contentDescription = null,
modifier = Modifier.size(SegmentedButtonDefaults.IconSize)
)
}
}
) {
Text(mode.label)
}
}
}
}SearchScreenContent
A thin private wrapper that delegates to the full SearchScreen composable. It passes searchViewModel, onNodeSelected, and the cross-mode resultActions bundle (which includes pin/center-on-graph callbacks). SearchScreen handles its own layout — the wrapper exists purely to keep the when branch in FindScreen clean and to provide a single point of control if search-specific behavior changes.
/**
* Search screen content without its own Scaffold (embedded in FindScreen)
*/
@Composable
private fun SearchScreenContent(
viewModel: SearchViewModel,
onNodeSelected: (nodeId: String) -> Unit,
resultActions: SearchResultActions? = null
) {
// Delegate to the existing SearchScreen but we need to extract the content
// For now, just use the full SearchScreen - it handles its own layout
SearchScreen(
viewModel = viewModel,
onNodeSelected = onNodeSelected,
resultActions = resultActions
)
}FilesScreenContent
A thin private wrapper that delegates to FilesScreen with embedded = true. The embedded flag tells FilesScreen to omit its own Scaffold and top bar, since the Find container already provides the mode selector. The onFileSelected callback resolves the target node via RoamRepository and navigates to it.
/**
* Files screen content without its own Scaffold (embedded in FindScreen)
*/
@Composable
private fun FilesScreenContent(
viewModel: FilesViewModel,
onFileSelected: (filePath: String) -> Unit
) {
FilesScreen(
viewModel = viewModel,
onFileSelected = onFileSelected,
embedded = true
)
}Source Code
The complete Kotlin source file, assembled from the named blocks above.
computer.whatthefuck.arcology.app.ui.screens.FindScreen
package computer.whatthefuck.arcology.app.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.app.viewmodel.FilesViewModel
import computer.whatthefuck.arcology.app.viewmodel.GraphViewModel
import computer.whatthefuck.arcology.app.viewmodel.MapViewModel
import computer.whatthefuck.arcology.app.viewmodel.SearchViewModel
import computer.whatthefuck.arcology.database.RoamRepository
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject<<findscreen-preamble>>
<<findscreen-findmode>>
<<findscreen-composable>>
<<findscreen-modeselector>>
<<findscreen-search-content>>
<<findscreen-files-content>>Related Modules
Search Screen — search bar, tag filtering, result display,
SearchViewModelMap Screen — OSM-based location map,
MapViewModelGraph Screen — node graph visualization with pin management,
GraphViewModelFiles Screen — file browser,
FilesViewModelApp Bootstrap — Koin DI, navigation graph,
MainActivitywhere ViewModels are hoistedApp Data Layer —
AppPreferences,RoamRepository