The Projects tab (inside AgendaScreen) shows org files that contain task headings as "projects". Each project card summarizes the task state (done/open counts, stuck badge), and selecting one opens a detail view: the file's tasks as compact cards scrolling into the full OrgDocumentRenderer for the file.
A project is stuck when all its task headings are in a final state (DONE/CANCELLED/ARCHIVED) and the file has not been retired. A project is retired when its level-0 node carries a CLOSED filetag. Retired projects are hidden by default; a toggle reveals them. Projects with any open task (TODO/NEXT/INPROGRESS) are never stuck.
The ProjectViewModel loads file summaries from AgendaRepository, enriches them with file-level tags from RoamRepository (for retirement detection and the tag filter), and filters in-memory. When a project is selected, the ViewModel reads the file via AndroidFileSystem and parses it off-thread through OrgDocumentCache.
The ViewModel and UI source blocks are composed via noweb. The file is organized by feature area: shared infrastructure first, then the Project Tab and Tagged Tasks Tab sections, with ViewModel methods and tests placed next to the composables they support.
TaskListViewModel
Shared task-list state machine used by both ProjectViewModel (by-file) and TaggedTasksViewModel (by-tag). This is not a ViewModel — it is a plain class that receives a CoroutineScope so each parent gets an independent instance with its own detail state.
Core State
The class declaration, constructor, state flows (detailTasks, detailDocument, isLoadingDetail, errorMessage, navEvents), and the todoStates property that reads from app preferences.
/**
* Shared task-list state and actions used by both [ProjectViewModel] (by-file)
* and [TaggedTasksViewModel] (by-tag).
*
* This is NOT a [ViewModel] — it is a plain class that receives a
* [CoroutineScope] (the parent ViewModel's =viewModelScope=) so each parent
* gets an independent instance with its own detail state. When the parent
* ViewModel is cleared, the scope is cancelled and all coroutines launched
* here are cleaned up.
*
* Holds: detail tasks, parsed document, loading flag, error messages, nav
* events, and the available TODO states for the state picker dialog.
* Provides: [loadDetail] (read file + parse), [loadTasks] (use pre-fetched
* task list, e.g. from a tag query), [clearDetail], [openTask] (resolve node
* and emit [ProjectNavEvent.OpenNode]), [updateTaskState] (delegate to
* [OrgDocumentEditor.updateTodoStateByPosition] then reload).
*
* @param scope The parent ViewModel's viewModelScope.
* @param agendaRepository Source of per-file task lists.
* @param roamRepository Used to resolve file-level nodes for [openTask].
* @param documentEditor Used for TODO state changes.
* @param appPreferences Used to read the available TODO states for the picker.
* @param fileSystemFactory Lazily creates an [AndroidFileSystem] for reading file content.
* @param backgroundDispatcher Overridable for tests.
*/
class TaskListViewModel(
private val scope: CoroutineScope,
private val agendaRepository: AgendaRepository,
private val roamRepository: RoamRepository,
private val documentEditor: OrgDocumentEditor,
private val appPreferences: AppPreferencesInterface,
private val fileSystemFactory: () -> AndroidFileSystem,
private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
private val _detailTasks = MutableStateFlow<List<TaskHeading>>(emptyList())
val detailTasks: StateFlow<List<TaskHeading>> = _detailTasks.asStateFlow()
private val _detailDocument = MutableStateFlow<OrgParseResult?>(null)
val detailDocument: StateFlow<OrgParseResult?> = _detailDocument.asStateFlow()
private val _isLoadingDetail = MutableStateFlow(false)
val isLoadingDetail: StateFlow<Boolean> = _isLoadingDetail.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
private val _navEvents = MutableSharedFlow<ProjectNavEvent>(extraBufferCapacity = 4)
val navEvents: SharedFlow<ProjectNavEvent> = _navEvents.asSharedFlow()
val todoStates: List<String> get() = appPreferences.getTodoStates()Detail Loading
loadDetail reads a file via AndroidFileSystem, parses it off-thread via OrgDocumentCache, and populates the detail tasks and document state flows. loadTasks accepts a pre-fetched task list (used by tag drill-down). clearDetail resets the detail state.
/**
* Load a file's tasks and parsed document for the detail view.
* Reads the file via [AndroidFileSystem] and parses it off-thread via
* [OrgDocumentCache].
*/
fun loadDetail(file: String) {
_isLoadingDetail.value = true
_errorMessage.value = null
scope.launch {
try {
val tasks = agendaRepository.getTaskHeadingsByFile(file)
_detailTasks.value = tasks
val fs = fileSystemFactory()
val content = withContext(backgroundDispatcher) { fs.readFile(file) }
val parseResult = withContext(backgroundDispatcher) {
OrgDocumentCache.parse(file, content)
}
_detailDocument.value = parseResult
} catch (e: Exception) {
Log.e(TAG, "loadDetail failed for $file", e)
_errorMessage.value = "Failed to load: ${e.message}"
} finally {
_isLoadingDetail.value = false
}
}
}
/**
* Use a pre-fetched task list for the detail view (e.g., tasks from a tag
* query). Does NOT load the document — the tag detail screen shows only the
* task cards.
*/
fun loadTasks(tasks: List<TaskHeading>) {
_detailTasks.value = tasks
_detailDocument.value = null
}
fun clearDetail() {
_detailTasks.value = emptyList()
_detailDocument.value = null
}Task Navigation
openTask resolves a task heading to a node ID and emits ProjectNavEvent.OpenNode. When the task has no nodeId, it falls back to the file-level node (level 0) from RoamRepository.
/**
* Resolve a task to a node id and emit [ProjectNavEvent.OpenNode].
* Uses the heading's nodeId if present, otherwise falls back to the
* file-level node (level 0).
*/
fun openTask(task: TaskHeading) {
scope.launch {
val nodeId = task.nodeId ?: run {
val nodes = roamRepository.getNodesByFile(task.file)
val target = nodes.firstOrNull { it.level == 0 } ?: nodes.firstOrNull()
target?.id
}
if (nodeId != null) {
_navEvents.tryEmit(ProjectNavEvent.OpenNode(nodeId))
} else {
_errorMessage.value = "No node found for ${task.file.substringAfterLast("/")}"
}
}
}TODO State Update
updateTaskState delegates to OrgDocumentEditor.updateTodoStateByPosition and re-loads the detail view on success so the UI reflects the change.
/**
* Update the TODO state of a task heading identified by (file, position).
* Re-loads the detail view on success so the UI reflects the change.
*/
fun updateTaskState(file: String, position: Int, newState: String?) {
scope.launch {
try {
val result = documentEditor.updateTodoStateByPosition(file, position, newState)
if (result is EditResult.Error) {
_errorMessage.value = result.message
} else {
// Reload the current detail if it matches the edited file.
val currentTasks = _detailTasks.value
if (currentTasks.isNotEmpty() && currentTasks.any { it.file == file }) {
// If we have a document, reload it; otherwise just refresh tasks
if (_detailDocument.value != null) {
loadDetail(file)
} else {
_detailTasks.value = agendaRepository.getTaskHeadingsByFile(file)
}
}
}
} catch (e: Exception) {
Log.e(TAG, "updateTaskState failed for $file:$position", e)
_errorMessage.value = e.message
}
}
}
}TaskListViewModel Tests
package computer.whatthefuck.arcology.app.viewmodel
import android.util.Log
import computer.whatthefuck.arcology.app.data.AppPreferencesInterface
import computer.whatthefuck.arcology.database.AgendaRepository
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.domain.TaskHeading
import computer.whatthefuck.arcology.editor.EditResult
import computer.whatthefuck.arcology.editor.OrgDocumentEditor
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class TaskListViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var agendaRepo: AgendaRepository
private lateinit var roamRepo: RoamRepository
private lateinit var documentEditor: OrgDocumentEditor
private lateinit var appPreferences: AppPreferencesInterface
private lateinit var fileSystem: AndroidFileSystem
private lateinit var scope: CoroutineScope
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockkStatic(Log::class)
every { Log.e(any<String>(), any<String>()) } returns 0
every { Log.e(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.d(any<String>(), any<String>()) } returns 0
agendaRepo = mockk()
roamRepo = mockk(relaxed = true)
documentEditor = mockk(relaxed = true)
appPreferences = mockk()
every { appPreferences.getTodoStates() } returns listOf("TODO", "NEXT", "DONE")
fileSystem = mockk(relaxed = true)
scope = CoroutineScope(SupervisorJob() + testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createTaskList(): TaskListViewModel {
return TaskListViewModel(
scope = scope,
agendaRepository = agendaRepo,
roamRepository = roamRepo,
documentEditor = documentEditor,
appPreferences = appPreferences,
fileSystemFactory = { fileSystem },
backgroundDispatcher = testDispatcher
)
}
private fun task(file: String = "/test.org", position: Int = 10): TaskHeading {
return TaskHeading(
file = file, position = position, level = 1, todo = "TODO",
priority = null, title = "Task $position", outlinePath = emptyList(),
nodeId = "node-$position", tags = emptyList(), isHabit = false,
timestamps = emptyList()
)
}}loadTasks
@Test
fun `loadTasks sets detail tasks without parsing document`() = runTest(testDispatcher) {
val tl = createTaskList()
val tasks = listOf(task(position = 10), task(position = 20))
tl.loadTasks(tasks)
advanceUntilIdle()
tl.detailTasks.first() shouldBe tasks
tl.detailDocument.first() shouldBe null
}clearDetail
@Test
fun `clearDetail empties tasks and document`() = runTest(testDispatcher) {
val tl = createTaskList()
tl.loadTasks(listOf(task()))
tl.clearDetail()
advanceUntilIdle()
tl.detailTasks.first().isEmpty() shouldBe true
tl.detailDocument.first() shouldBe null
}updateTaskState
@Test
fun `updateTaskState delegates to documentEditor`() = runTest(testDispatcher) {
coEvery { documentEditor.updateTodoStateByPosition(any(), any(), any()) } returns EditResult.Success("")
coEvery { agendaRepo.getTaskHeadingsByFile(any()) } returns emptyList()
val tl = createTaskList()
tl.loadTasks(listOf(task()))
tl.updateTaskState("/test.org", 10, "DONE")
advanceUntilIdle()
coVerify { documentEditor.updateTodoStateByPosition("/test.org", 10, "DONE") }
}openTask with nodeId
@Test
fun `openTask emits OpenNode with heading nodeId`() = runTest(testDispatcher) {
val tl = createTaskList()
val t = task(position = 10).copy(nodeId = "abc-123")
val eventDeferred = async { tl.navEvents.first() }
tl.openTask(t)
advanceUntilIdle()
val event = eventDeferred.await()
(event as ProjectNavEvent.OpenNode).nodeId shouldBe "abc-123"
}openTask fallback
@Test
fun `openTask falls back to file-level node when nodeId is null`() = runTest(testDispatcher) {
val t = task(position = 10).copy(nodeId = null)
val fileNode = OrgNode(
id = "file-node", file = "/test.org", level = 0, position = 0, title = "File"
)
coEvery { roamRepo.getNodesByFile("/test.org") } returns listOf(fileNode)
val tl = createTaskList()
val eventDeferred = async { tl.navEvents.first() }
tl.openTask(t)
advanceUntilIdle()
val event = eventDeferred.await()
(event as ProjectNavEvent.OpenNode).nodeId shouldBe "file-node"
}todoStates
@Test
fun `todoStates reads from appPreferences`() {
val tl = createTaskList()
tl.todoStates shouldBe listOf("TODO", "NEXT", "DONE")
}ProjectItem
The UI-facing project summary data class. fromSummary builds a ProjectItem from a FileTaskSummary, extracting the display title from the filename.
/**
* UI-facing project summary with computed stuck/retired flags and tags.
*/
data class ProjectItem(
val file: String,
val title: String,
val doneCount: Long,
val openCount: Long,
val totalCount: Long,
val isStuck: Boolean,
val isRetired: Boolean,
val tags: List<String>
) {
companion object {
fun fromSummary(summary: FileTaskSummary): ProjectItem {
val title = summary.file.substringAfterLast("/").removeSuffix(".org")
return ProjectItem(
file = summary.file,
title = title,
doneCount = summary.doneCount,
openCount = summary.openCount,
totalCount = summary.totalCount,
isStuck = summary.isStuck,
isRetired = summary.isRetired,
tags = summary.tags
)
}
}
}ProjectNavEvent
Sealed class for navigation events emitted by the task list detail view.
sealed class ProjectNavEvent {
data class OpenNode(val nodeId: String) : ProjectNavEvent()
}Project Tab
The by-file project browser: a filterable list of project cards, and a detail view showing task cards with full document rendering.
Project List
The project list screen with search, stuck/retired toggles, tag filter dropdown, multi-select mode for bulk retire, and pull-to-refresh.
ProjectViewModel State
The ViewModel class declaration, state flows (projects, selectedFile, searchQuery, stuckOnly, etc.), delegated task-list state, and the cached allProjects list.
/**
* View model for the Projects tab (by-file view).
*
* @param agendaRepository Source of task summaries and per-file task lists.
* @param roamRepository Used to resolve file-level nodes and tags (for retirement detection).
* @param documentEditor Used for TODO state changes and retiring (addFileTag).
* @param appPreferences Used to read the available TODO states for the state picker.
* @param workManager Used to observe indexing completion and auto-refresh.
* @param fileSystemFactory Lazily creates an [AndroidFileSystem] for reading file content.
* @param backgroundDispatcher Overridable for tests.
*/
class ProjectViewModel(
private val agendaRepository: AgendaRepository,
private val roamRepository: RoamRepository,
private val documentEditor: OrgDocumentEditor,
private val appPreferences: AppPreferencesInterface,
private val workManager: WorkManager? = null,
private val fileSystemFactory: () -> AndroidFileSystem,
private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.Default
) : ViewModel() {
val taskList = TaskListViewModel(
scope = viewModelScope,
agendaRepository = agendaRepository,
roamRepository = roamRepository,
documentEditor = documentEditor,
appPreferences = appPreferences,
fileSystemFactory = fileSystemFactory,
backgroundDispatcher = backgroundDispatcher
)
private val _projects = MutableStateFlow<List<ProjectItem>>(emptyList())
val projects: StateFlow<List<ProjectItem>> = _projects.asStateFlow()
private val _selectedFile = MutableStateFlow<String?>(null)
val selectedFile: StateFlow<String?> = _selectedFile.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _stuckOnly = MutableStateFlow(false)
val stuckOnly: StateFlow<Boolean> = _stuckOnly.asStateFlow()
private val _retiredVisible = MutableStateFlow(false)
val retiredVisible: StateFlow<Boolean> = _retiredVisible.asStateFlow()
private val _selectedTag = MutableStateFlow<String?>(null)
val selectedTag: StateFlow<String?> = _selectedTag.asStateFlow()
private val _availableTags = MutableStateFlow<List<String>>(emptyList())
val availableTags: StateFlow<List<String>> = _availableTags.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
// Delegated task-list state
val detailTasks: StateFlow<List<TaskHeading>> = taskList.detailTasks
val detailDocument: StateFlow<OrgParseResult?> = taskList.detailDocument
val isLoadingDetail: StateFlow<Boolean> = taskList.isLoadingDetail
val navEvents: SharedFlow<ProjectNavEvent> = taskList.navEvents
val todoStates: List<String> get() = taskList.todoStates
// Cached unfiltered list; filtering applied in-memory on top of this.
private var allProjects: List<ProjectItem> = emptyList()Indexing Observation
Observes the indexing worker. When indexing succeeds, reloads the project list so newly indexed files and updated task states appear.
init {
loadProjects()
observeIndexingCompletion()
}
/**
* Observe the indexing worker. When indexing succeeds, reload the project
* list so newly indexed files and updated task states appear.
*/
private fun observeIndexingCompletion() {
val wm = workManager ?: return
viewModelScope.launch {
wm.getWorkInfosForUniqueWorkFlow(IndexingWorker.WORK_NAME)
.collect { workInfos ->
val workInfo = workInfos.firstOrNull()
if (workInfo?.state == WorkInfo.State.SUCCEEDED) {
loadProjects()
}
}
}
}Project Data Loading
loadProjects fetches file task summaries from AgendaRepository, enriches them with file-level tags for retirement detection and titles from RoamRepository, and applies in-memory filters. refresh triggers a reload. applyFilters combines search query, stuck filter, retired visibility, and tag filter into the emitted projects flow.
fun loadProjects() {
_isLoading.value = true
_errorMessage.value = null
viewModelScope.launch {
try {
val summaries = agendaRepository.getFileTaskStateSummaries()
val tags = agendaRepository.getProjectTags()
_availableTags.value = tags
// Batch-fetch tags for each file's level-0 node to determine retirement.
// Also fetch the file title (from the files table) for display.
val enriched = summaries.map { summary ->
val fileTags = agendaRepository.getFileTags(summary.file)
val isRetired = fileTags.contains("CLOSED")
val isStuck = summary.openCount == 0L && summary.doneCount > 0L && !isRetired
val title = runCatching {
roamRepository.getFileByPath(summary.file)?.title
}.getOrNull()
ProjectItem.fromSummary(summary.copy(isStuck = isStuck, isRetired = isRetired, tags = fileTags))
.let { it.copy(title = title ?: it.title) }
}
allProjects = enriched
applyFilters()
} catch (e: Exception) {
Log.e(TAG, "loadProjects failed", e)
_errorMessage.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun refresh() {
loadProjects()
}
private fun applyFilters() {
val q = _searchQuery.value
val stuckOnly = _stuckOnly.value
val retiredVisible = _retiredVisible.value
val tag = _selectedTag.value
_projects.value = allProjects.filter { item ->
(!item.isRetired || retiredVisible) &&
(!stuckOnly || item.isStuck) &&
(tag == null || item.tags.contains(tag)) &&
(q.isBlank() || item.file.contains(q, ignoreCase = true) ||
item.title.contains(q, ignoreCase = true))
}
}Filter Controls
Public methods that update filter state and re-apply filtering: updateSearchQuery, toggleStuckOnly, toggleRetiredVisible, and selectTag.
fun updateSearchQuery(q: String) {
_searchQuery.value = q
applyFilters()
}
fun toggleStuckOnly() {
_stuckOnly.value = !_stuckOnly.value
applyFilters()
}
fun toggleRetiredVisible() {
_retiredVisible.value = !_retiredVisible.value
applyFilters()
}
fun selectTag(tag: String?) {
_selectedTag.value = tag
applyFilters()
}Project Retirement
retireProject adds a CLOSED filetag and reloads the project list. bulkRetire does the same for multiple files, counting errors.
/**
* Retire a project by adding a CLOSED filetag to the file's =#+FILETAGS:= line.
* Called from the Stuck chip confirm dialog.
*/
fun retireProject(file: String) {
viewModelScope.launch {
try {
val result = documentEditor.addFileTag(file, "CLOSED")
if (result is EditResult.Error) {
_errorMessage.value = result.message
} else {
loadProjects()
}
} catch (e: Exception) {
Log.e(TAG, "retireProject failed for $file", e)
_errorMessage.value = e.message
}
}
}
/**
* Bulk-retire multiple projects by adding a CLOSED filetag to each.
* Used by the multi-select "Retire selected" action in the stuck view.
*/
fun bulkRetire(files: List<String>) {
viewModelScope.launch {
var errors = 0
for (file in files) {
try {
val result = documentEditor.addFileTag(file, "CLOSED")
if (result is EditResult.Error) errors++
} catch (e: Exception) {
Log.e(TAG, "bulkRetire failed for $file", e)
errors++
}
}
if (errors > 0) {
_errorMessage.value = "Failed to retire $errors of ${files.size} projects"
}
loadProjects()
}
}ProjectViewModel Tests
Tests for the project listing, filtering, retirement, and task state update behavior.
package computer.whatthefuck.arcology.app.viewmodel
import android.util.Log
import computer.whatthefuck.arcology.app.data.AppPreferencesInterface
import computer.whatthefuck.arcology.database.AgendaRepository
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.FileTaskSummary
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.domain.TaskHeading
import computer.whatthefuck.arcology.editor.EditResult
import computer.whatthefuck.arcology.editor.OrgDocumentEditor
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ProjectViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var agendaRepo: AgendaRepository
private lateinit var roamRepo: RoamRepository
private lateinit var documentEditor: OrgDocumentEditor
private lateinit var appPreferences: AppPreferencesInterface
private lateinit var fileSystem: AndroidFileSystem
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockkStatic(Log::class)
every { Log.e(any<String>(), any<String>()) } returns 0
every { Log.e(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.d(any<String>(), any<String>()) } returns 0
agendaRepo = mockk()
roamRepo = mockk(relaxed = true)
documentEditor = mockk(relaxed = true)
appPreferences = mockk()
every { appPreferences.getTodoStates() } returns listOf("TODO", "NEXT", "DONE")
fileSystem = mockk(relaxed = true)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel(): ProjectViewModel {
return ProjectViewModel(
agendaRepository = agendaRepo,
roamRepository = roamRepo,
documentEditor = documentEditor,
appPreferences = appPreferences,
workManager = null,
fileSystemFactory = { fileSystem },
backgroundDispatcher = testDispatcher
)
}
private fun summary(file: String, done: Long, open: Long): FileTaskSummary {
return FileTaskSummary(
file = file,
doneCount = done,
openCount = open,
totalCount = done + open
)
}}stuck detection
@Test
fun `all tasks done and no CLOSED tag means stuck`() = runTest(testDispatcher) {
coEvery { agendaRepo.getFileTaskStateSummaries() } returns listOf(
summary("projects/orgmode.org", 3, 0)
)
coEvery { agendaRepo.getProjectTags() } returns emptyList()
coEvery { agendaRepo.getFileTags("projects/orgmode.org") } returns listOf("project")
val vm = createViewModel()
advanceUntilIdle()
val projects = vm.projects.first()
projects.size shouldBe 1
projects[0].isStuck shouldBe true
projects[0].isRetired shouldBe false
}retired not stuck
@Test
fun `all tasks done with CLOSED tag means retired not stuck`() = runTest(testDispatcher) {
coEvery { agendaRepo.getFileTaskStateSummaries() } returns listOf(
summary("projects/done.org", 2, 0)
)
coEvery { agendaRepo.getProjectTags() } returns listOf("CLOSED")
coEvery { agendaRepo.getFileTags("projects/done.org") } returns listOf("CLOSED")
val vm = createViewModel()
advanceUntilIdle()
// Retired is hidden by default — toggle to see it
vm.toggleRetiredVisible()
advanceUntilIdle()
val projects = vm.projects.first()
projects.size shouldBe 1
projects[0].isStuck shouldBe false
projects[0].isRetired shouldBe true
}not stuck with open task
@Test
fun `one open task means not stuck`() = runTest(testDispatcher) {
coEvery { agendaRepo.getFileTaskStateSummaries() } returns listOf(
summary("projects/active.org", 1, 2)
)
coEvery { agendaRepo.getProjectTags() } returns emptyList()
coEvery { agendaRepo.getFileTags("projects/active.org") } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
val projects = vm.projects.first()
projects[0].isStuck shouldBe false
projects[0].isRetired shouldBe false
}retired hidden by default
@Test
fun `retired hidden by default`() = runTest(testDispatcher) {
coEvery { agendaRepo.getFileTaskStateSummaries() } returns listOf(
summary("projects/done.org", 2, 0),
summary("projects/active.org", 1, 1)
)
coEvery { agendaRepo.getProjectTags() } returns emptyList()
coEvery { agendaRepo.getFileTags("projects/done.org") } returns listOf("CLOSED")
coEvery { agendaRepo.getFileTags("projects/active.org") } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
val projects = vm.projects.first()
projects.size shouldBe 1
projects[0].file shouldBe "projects/active.org"
}retired toggle
@Test
fun `retired visible toggle shows retired`() = runTest(testDispatcher) {
coEvery { agendaRepo.getFileTaskStateSummaries() } returns listOf(
summary("projects/done.org", 2, 0),
summary("projects/active.org", 1, 1)
)
coEvery { agendaRepo.getProjectTags() } returns emptyList()
coEvery { agendaRepo.getFileTags("projects/done.org") } returns listOf("CLOSED")
coEvery { agendaRepo.getFileTags("projects/active.org") } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
vm.toggleRetiredVisible()
advanceUntilIdle()
val projects = vm.projects.first()
projects.size shouldBe 2
}stuck filter
@Test
fun `stuck only toggle filters`() = runTest(testDispatcher) {
coEvery { agendaRepo.getFileTaskStateSummaries() } returns listOf(
summary("projects/stuck.org", 3, 0),
summary("projects/active.org", 1, 1)
)
coEvery { agendaRepo.getProjectTags() } returns emptyList()
coEvery { agendaRepo.getFileTags(any()) } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
vm.toggleStuckOnly()
advanceUntilIdle()
val projects = vm.projects.first()
projects.size shouldBe 1
projects[0].file shouldBe "projects/stuck.org"
}search filter
@Test
fun `search query filters by title`() = runTest(testDispatcher) {
coEvery { agendaRepo.getFileTaskStateSummaries() } returns listOf(
summary("projects/orgmode.org", 1, 1),
summary("projects/arcology.org", 0, 2)
)
coEvery { agendaRepo.getProjectTags() } returns emptyList()
coEvery { agendaRepo.getFileTags(any()) } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
vm.updateSearchQuery("arc")
advanceUntilIdle()
val projects = vm.projects.first()
projects.size shouldBe 1
projects[0].file shouldBe "projects/arcology.org"
}tag filter
@Test
fun `tag filter narrows list`() = runTest(testDispatcher) {
coEvery { agendaRepo.getFileTaskStateSummaries() } returns listOf(
summary("projects/tagged.org", 0, 1),
summary("projects/untagged.org", 0, 1)
)
coEvery { agendaRepo.getProjectTags() } returns listOf("work")
coEvery { agendaRepo.getFileTags("projects/tagged.org") } returns listOf("work")
coEvery { agendaRepo.getFileTags("projects/untagged.org") } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
vm.selectTag("work")
advanceUntilIdle()
val projects = vm.projects.first()
projects.size shouldBe 1
projects[0].file shouldBe "projects/tagged.org"
}retireProject
@Test
fun `retireProject calls addFileTag and reloads`() = runTest(testDispatcher) {
coEvery { agendaRepo.getFileTaskStateSummaries() } returns listOf(
summary("projects/stuck.org", 3, 0)
)
coEvery { agendaRepo.getProjectTags() } returns emptyList()
coEvery { agendaRepo.getFileTags(any()) } returns emptyList()
coEvery { documentEditor.addFileTag(any(), any()) } returns EditResult.Success("")
val vm = createViewModel()
advanceUntilIdle()
vm.retireProject("projects/stuck.org")
advanceUntilIdle()
coVerify { documentEditor.addFileTag("projects/stuck.org", "CLOSED") }
}updateTaskState
@Test
fun `updateTaskState delegates to documentEditor`() = runTest(testDispatcher) {
coEvery { agendaRepo.getFileTaskStateSummaries() } returns emptyList()
coEvery { agendaRepo.getProjectTags() } returns emptyList()
coEvery { documentEditor.updateTodoStateByPosition(any(), any(), any()) } returns EditResult.Success("")
coEvery { agendaRepo.getTaskHeadingsByFile(any()) } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
vm.updateTaskState("projects/foo.org", 42, "DONE")
advanceUntilIdle()
coVerify { documentEditor.updateTodoStateByPosition("projects/foo.org", 42, "DONE") }
}title override
@Test
fun `file title from RoamRepository overrides filename`() = runTest(testDispatcher) {
coEvery { agendaRepo.getFileTaskStateSummaries() } returns listOf(
summary("projects/20260801120000.org", 0, 1)
)
coEvery { agendaRepo.getProjectTags() } returns emptyList()
coEvery { agendaRepo.getFileTags(any()) } returns emptyList()
coEvery { roamRepo.getFileByPath("projects/20260801120000.org") } returns
computer.whatthefuck.arcology.domain.OrgFile(
path = "projects/20260801120000.org",
title = "My Cool Project",
hash = "x",
accessTime = kotlin.time.Instant.fromEpochSeconds(0),
modificationTime = kotlin.time.Instant.fromEpochSeconds(0)
)
val vm = createViewModel()
advanceUntilIdle()
val projects = vm.projects.first()
projects[0].title shouldBe "My Cool Project"
}ProjectsListScreen Composable
The project list screen: filter bar (search, stuck/retired/tag chips), multi-select mode for bulk retire (gated on stuck filter), pull-to-refresh with LazyColumn, and retire confirmation dialog.
@Composable
private fun ProjectsListScreen(viewModel: ProjectViewModel) {
val projects by viewModel.projects.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val stuckOnly by viewModel.stuckOnly.collectAsState()
val retiredVisible by viewModel.retiredVisible.collectAsState()
val selectedTag by viewModel.selectedTag.collectAsState()
val availableTags by viewModel.availableTags.collectAsState()
var tagMenuExpanded by remember { mutableStateOf(false) }
// Multi-select mode: available when Stuck filter is active.
var selectionMode by remember { mutableStateOf(false) }
val selectedFiles = remember { mutableStateMapOf<String, Boolean>() }
val scope = rememberCoroutineScope()
BackHandler(selectionMode) { selectionMode = false }
Column(modifier = Modifier.fillMaxSize()) {
// Filter bar
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
OutlinedTextField(
value = searchQuery,
onValueChange = { viewModel.updateSearchQuery(it) },
label = { Text("Search projects") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = stuckOnly,
onClick = { viewModel.toggleStuckOnly() },
leadingIcon = {
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(16.dp))
},
label = { Text("Stuck") }
)
FilterChip(
selected = retiredVisible,
onClick = { viewModel.toggleRetiredVisible() },
label = { Text("Retired") }
)
Box {
FilterChip(
selected = selectedTag != null,
onClick = { tagMenuExpanded = true },
label = { Text(selectedTag ?: "Tag") }
)
DropdownMenu(
expanded = tagMenuExpanded,
onDismissRequest = { tagMenuExpanded = false }
) {
if (selectedTag != null) {
DropdownMenuItem(
text = { Text("Clear") },
onClick = { viewModel.selectTag(null); tagMenuExpanded = false }
)
}
availableTags.forEach { tag ->
DropdownMenuItem(
text = { Text(tag) },
onClick = { viewModel.selectTag(tag); tagMenuExpanded = false }
)
}
}
}
if (stuckOnly) {
FilterChip(
selected = selectionMode,
onClick = {
selectionMode = !selectionMode
if (!selectionMode) selectedFiles.clear()
},
label = { Text("Select") }
)
}
}
}
if (isLoading && projects.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (projects.isEmpty()) {
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(
"No projects found. Files with TODO headings appear here.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
} else {
var retireTarget by remember { mutableStateOf<ProjectItem?>(null) }
val isRefreshing = remember { mutableStateOf(false) }
if (selectionMode && stuckOnly) {
// Bulk action bar
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"${selectedFiles.count { it.value }} selected",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
TextButton(
onClick = {
val toRetire = projects.filter { selectedFiles[it.file] == true }.map { it.file }
if (toRetire.isNotEmpty()) {
viewModel.bulkRetire(toRetire)
}
selectedFiles.clear()
selectionMode = false
}
) { Text("Retire selected") }
TextButton(onClick = { selectedFiles.clear(); selectionMode = false }) {
Text("Cancel")
}
}
}
PullToRefreshBox(
isRefreshing = isRefreshing.value,
onRefresh = {
isRefreshing.value = true
viewModel.refresh()
// Give the refresh a moment to fire, then clear the indicator
scope.launch {
kotlinx.coroutines.delay(500)
isRefreshing.value = false
}
},
modifier = Modifier.fillMaxSize()
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(projects, key = { it.file }) { project ->
val isSelected = selectedFiles[project.file] == true
ProjectCard(
project = project,
onClick = {
if (selectionMode && stuckOnly) {
selectedFiles[project.file] = !isSelected
} else {
viewModel.selectProject(project.file)
}
},
onRetireRequest = { retireTarget = project },
selectionMode = selectionMode && stuckOnly,
isSelected = isSelected,
onSelectionToggle = { selectedFiles[project.file] = !isSelected }
)
HorizontalDivider()
}
}
}
retireTarget?.let { target ->
AlertDialog(
onDismissRequest = { retireTarget = null },
title = { Text("Retire project?") },
text = { Text("This project is stuck (all tasks are done). Add a CLOSED filetag to retire it?") },
confirmButton = {
TextButton(onClick = {
viewModel.retireProject(target.file)
retireTarget = null
}) { Text("Retire") }
},
dismissButton = {
TextButton(onClick = { retireTarget = null }) { Text("Cancel") }
}
)
}
}
}
}ProjectCard Composable
A single project card showing title, done/open counts, a mini progress bar, and stuck/retired badge chips.
@Composable
private fun ProjectCard(
project: ProjectItem,
onClick: () -> Unit,
onRetireRequest: () -> Unit = {},
selectionMode: Boolean = false,
isSelected: Boolean = false,
onSelectionToggle: () -> Unit = {}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (selectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { onSelectionToggle() },
modifier = Modifier.padding(end = 8.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = project.title,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${project.doneCount}/${project.totalCount} done" +
if (project.openCount > 0) " · ${project.openCount} open" else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Mini progress bar
if (project.totalCount > 0) {
LinearProgressIndicator(
progress = { (project.doneCount.toFloat() / project.totalCount.toFloat()).coerceIn(0f, 1f) },
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp)
.height(4.dp)
)
}
}
// Badges
if (project.isStuck) {
AssistChip(
onClick = onRetireRequest,
leadingIcon = { Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(14.dp)) },
label = { Text("Stuck", style = MaterialTheme.typography.labelSmall) },
modifier = Modifier.padding(start = 8.dp).heightIn(max = 26.dp),
shape = RoundedCornerShape(13.dp)
)
} else if (project.isRetired) {
AssistChip(
onClick = {},
label = { Text("Retired", style = MaterialTheme.typography.labelSmall) },
modifier = Modifier.padding(start = 8.dp).heightIn(max = 26.dp),
shape = RoundedCornerShape(13.dp)
)
}
}
}Details
The project detail view: header with back button, task cards scrolling into the full OrgDocumentRenderer, and a TODO state picker dialog.
Select Project & Delegation
selectProject loads the file's detail when a project is tapped. Thin delegation methods for openTask and updateTaskState pass through to the shared TaskListViewModel.
fun selectProject(file: String?) {
_selectedFile.value = file
if (file != null) {
taskList.loadDetail(file)
} else {
taskList.clearDetail()
}
}
fun openTask(task: TaskHeading) = taskList.openTask(task)
fun updateTaskState(file: String, position: Int, newState: String?) =
taskList.updateTaskState(file, position, newState)
}ProjectDetailScreen Composable
The full detail screen: header with back navigation, task card list, loading indicator, OrgDocumentRenderer integration, and the TODO state picker dialog.
@Composable
private fun ProjectDetailScreen(
viewModel: ProjectViewModel,
onBack: () -> Unit
) {
val selectedFile by viewModel.selectedFile.collectAsState()
val projects by viewModel.projects.collectAsState()
val tasks by viewModel.detailTasks.collectAsState()
val detailDocument by viewModel.detailDocument.collectAsState()
val isLoadingDetail by viewModel.isLoadingDetail.collectAsState()
// Find the project title from the cached list, fall back to filename
val projectTitle = projects.firstOrNull { it.file == selectedFile }?.title
?: selectedFile?.substringAfterLast("/") ?: ""
// Hardware back button returns to project list
BackHandler { onBack() }
// TODO state picker dialog state
var todoEditTarget by remember { mutableStateOf<Triple<String, Int, String?>?>(null) }
val todoStates = viewModel.todoStates
Column(modifier = Modifier.fillMaxSize()) {
// Header with back button
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text(
text = projectTitle,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 8.dp)
)
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
// Task cards
if (tasks.isNotEmpty()) {
item {
Text(
"Tasks (${tasks.size})",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
items(tasks, key = { "${it.file}:${it.position}" }) { task ->
ProjectTaskCard(
task = task,
onClick = { viewModel.openTask(task) },
onTodoClick = { todoEditTarget = Triple(task.file, task.position, task.todo) }
)
HorizontalDivider()
}
item {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
"Document",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
)
}
}
// Document renderer
if (isLoadingDetail) {
item {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
} else {
val parseResult = detailDocument
if (parseResult is OrgParseResult.Success) {
item {
OrgDocumentRenderer(
document = parseResult.document,
mode = RenderMode.FULL_DOCUMENT,
onLinkClick = { nodeId ->
// Navigate to the node via the editor screen.
},
onTodoClick = { file, position, currentTodo ->
todoEditTarget = Triple(file, position, currentTodo)
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
)
}
} else if (parseResult is OrgParseResult.Failure) {
item {
Text(
text = "Failed to parse document: ${parseResult.error}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)
)
}
}
}
}
}
// TODO state picker dialog
TodoStatePickerDialog(
target = todoEditTarget,
todoStates = todoStates,
onDismiss = { todoEditTarget = null },
onSelect = { state ->
val (file, position, _) = todoEditTarget!!
viewModel.updateTaskState(file, position, state)
todoEditTarget = null
},
onClear = {
val (file, position, _) = todoEditTarget!!
viewModel.updateTaskState(file, position, null)
todoEditTarget = null
}
)
}Task Card
The compact task card composable: TODO state chip with color-coded container, title with date chips (scheduled/deadline), and click-to-navigate. Also the shared TodoStatePickerDialog.
ProjectTaskCard
@Composable
internal fun ProjectTaskCard(task: TaskHeading, onClick: () -> Unit, onTodoClick: () -> Unit = {}) {
val scheduled = task.timestamps.firstOrNull { it.kind.name == "SCHEDULED" }
val deadline = task.timestamps.firstOrNull { it.kind.name == "DEADLINE" }
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// TODO state chip
task.todo?.let { todo ->
val upper = todo.uppercase()
val (bg, fg) = when (upper) {
"TODO" -> MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer
"NEXT" -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer
"INPROGRESS" -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer
"DONE" -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer
"CANCELLED", "ARCHIVED" -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
color = bg,
shape = RoundedCornerShape(4.dp),
modifier = Modifier
.heightIn(max = 22.dp)
.clickable(onClick = onTodoClick)
) {
Text(
text = todo,
color = fg,
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = task.title ?: "(untitled)",
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
// Date chips
val dateParts = mutableListOf<String>()
scheduled?.let { dateParts.add("Sched: ${it.date}") }
deadline?.let { dateParts.add("Due: ${it.date}") }
if (dateParts.isNotEmpty()) {
Text(
text = dateParts.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}TodoStatePickerDialog
Shared dialog used by both the Projects and Tagged Tasks detail screens. Shows the available TODO states (from AppPreferences), highlights the current state, and offers a "Clear" action.
/**
* Shared TODO state picker dialog used by both the Projects and Tagged Tasks
* detail screens. Shows the available TODO states (from [AppPreferences]),
* highlights the current state, and offers a "Clear" action.
*
* @param target null when hidden; otherwise a Triple of (file, position, currentTodo).
* @param todoStates The selectable TODO states.
* @param onDismiss Called when the dialog is dismissed without a selection.
* @param onSelect Called with the selected state (never null).
* @param onClear Called to clear the TODO state (set to null).
*/
@Composable
fun TodoStatePickerDialog(
target: Triple<String, Int, String?>?,
todoStates: List<String>,
onDismiss: () -> Unit,
onSelect: (String) -> Unit,
onClear: () -> Unit
) {
target ?: return
val (_, _, currentTodo) = target
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("TODO State") },
text = {
Column {
todoStates.forEach { state ->
TextButton(
onClick = { onSelect(state) },
modifier = Modifier.fillMaxWidth()
) {
Text(
text = state + if (state == currentTodo) " ✓" else "",
color = if (state == currentTodo) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
TextButton(
onClick = onClear,
modifier = Modifier.fillMaxWidth()
) {
Text("Clear" + if (currentTodo == null) " ✓" else "")
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}Tagged Tasks Tab
The by-tag task browser: a filterable list of tag sections with untagged tasks at the top, and a detail view drilling into a specific tag's task list.
Tag List
The tag list screen with a tag name filter, untagged tasks section, grouped tag sections with counts, and pull-to-refresh.
TaggedTasksViewModel State
The ViewModel class declaration, state flows (untaggedTasks, tags, tagFilterQuery, visibleTags with reactive filtering, selectedTag), and delegated task-list state.
/**
* View model for the Tagged Tasks tab (by-tag view).
*
* Shows untagged tasks as a "meta" section at the top, then individual
* tag-grouped sections below. Tapping a tag drills into a detail screen
* showing that tag's task list — mirroring the Projects detail pattern.
*
* @param agendaRepository Source of task tags, untagged tasks, and tasks-by-tag.
* @param roamRepository Used to resolve file-level nodes for [openTask].
* @param documentEditor Used for TODO state changes.
* @param appPreferences Used to read the available TODO states for the state picker.
* @param workManager Used to observe indexing completion and auto-refresh.
* @param fileSystemFactory Lazily creates an [AndroidFileSystem] for reading file content.
* @param backgroundDispatcher Overridable for tests.
*/
class TaggedTasksViewModel(
private val agendaRepository: AgendaRepository,
private val roamRepository: RoamRepository,
private val documentEditor: OrgDocumentEditor,
private val appPreferences: AppPreferencesInterface,
private val workManager: WorkManager? = null,
private val fileSystemFactory: () -> AndroidFileSystem,
private val backgroundDispatcher: CoroutineDispatcher = Dispatchers.Default
) : ViewModel() {
val taskList = TaskListViewModel(
scope = viewModelScope,
agendaRepository = agendaRepository,
roamRepository = roamRepository,
documentEditor = documentEditor,
appPreferences = appPreferences,
fileSystemFactory = fileSystemFactory,
backgroundDispatcher = backgroundDispatcher
)
private val _untaggedTasks = MutableStateFlow<List<TaskHeading>>(emptyList())
val untaggedTasks: StateFlow<List<TaskHeading>> = _untaggedTasks.asStateFlow()
private val _tags = MutableStateFlow<List<Pair<String, Long>>>(emptyList())
val tags: StateFlow<List<Pair<String, Long>>> = _tags.asStateFlow()
private val _tagFilterQuery = MutableStateFlow("")
val tagFilterQuery: StateFlow<String> = _tagFilterQuery.asStateFlow()
val visibleTags: StateFlow<List<Pair<String, Long>>> = _tags
.map { all ->
val q = _tagFilterQuery.value
if (q.isBlank()) all else all.filter { it.first.contains(q, ignoreCase = true) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val _selectedTag = MutableStateFlow<String?>(null)
val selectedTag: StateFlow<String?> = _selectedTag.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
// Delegated task-list state
val detailTasks: StateFlow<List<TaskHeading>> = taskList.detailTasks
val navEvents: SharedFlow<ProjectNavEvent> = taskList.navEvents
val todoStates: List<String> get() = taskList.todoStatesIndexing Observation
Observes the indexing worker for auto-refresh on indexing completion.
init {
loadData()
observeIndexingCompletion()
}
private fun observeIndexingCompletion() {
val wm = workManager ?: return
viewModelScope.launch {
wm.getWorkInfosForUniqueWorkFlow(IndexingWorker.WORK_NAME)
.collect { workInfos ->
val workInfo = workInfos.firstOrNull()
if (workInfo?.state == WorkInfo.State.SUCCEEDED) {
loadData()
}
}
}
}Tag Data Loading
loadData fetches untagged tasks and tag-with-count pairs from AgendaRepository. updateTagFilter updates the reactive visibleTags flow. refresh triggers a reload.
fun loadData() {
_isLoading.value = true
_errorMessage.value = null
viewModelScope.launch {
try {
_untaggedTasks.value = agendaRepository.getUntaggedTaskHeadings()
_tags.value = agendaRepository.getTaskTagsWithCounts()
} catch (e: Exception) {
Log.e(TAG, "loadData failed", e)
_errorMessage.value = e.message
} finally {
_isLoading.value = false
}
}
}
fun updateTagFilter(q: String) {
_tagFilterQuery.value = q
}
fun refresh() {
loadData()
}TaggedTasksViewModel Tests
package computer.whatthefuck.arcology.app.viewmodel
import android.util.Log
import computer.whatthefuck.arcology.app.data.AppPreferencesInterface
import computer.whatthefuck.arcology.database.AgendaRepository
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.TaskHeading
import computer.whatthefuck.arcology.editor.OrgDocumentEditor
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class TaggedTasksViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var agendaRepo: AgendaRepository
private lateinit var roamRepo: RoamRepository
private lateinit var documentEditor: OrgDocumentEditor
private lateinit var appPreferences: AppPreferencesInterface
private lateinit var fileSystem: AndroidFileSystem
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockkStatic(Log::class)
every { Log.e(any<String>(), any<String>()) } returns 0
every { Log.e(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.d(any<String>(), any<String>()) } returns 0
agendaRepo = mockk()
roamRepo = mockk(relaxed = true)
documentEditor = mockk(relaxed = true)
appPreferences = mockk()
every { appPreferences.getTodoStates() } returns listOf("TODO", "NEXT", "DONE")
fileSystem = mockk(relaxed = true)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel(): TaggedTasksViewModel {
return TaggedTasksViewModel(
agendaRepository = agendaRepo,
roamRepository = roamRepo,
documentEditor = documentEditor,
appPreferences = appPreferences,
workManager = null,
fileSystemFactory = { fileSystem },
backgroundDispatcher = testDispatcher
)
}
private fun task(file: String = "/test.org", position: Int = 10): TaskHeading {
return TaskHeading(
file = file, position = position, level = 1, todo = "TODO",
priority = null, title = "Task $position", outlinePath = emptyList(),
nodeId = "node-$position", tags = emptyList(), isHabit = false,
timestamps = emptyList()
)
}}loadData
@Test
fun `loadData populates untagged tasks and tags`() = runTest(testDispatcher) {
coEvery { agendaRepo.getUntaggedTaskHeadings() } returns listOf(task(position = 10))
coEvery { agendaRepo.getTaskTagsWithCounts() } returns listOf("work" to 3L, "home" to 1L)
val vm = createViewModel()
advanceUntilIdle()
vm.untaggedTasks.first().size shouldBe 1
vm.tags.first() shouldBe listOf("work" to 3L, "home" to 1L)
}error handling
@Test
fun `errorMessage set when loadData throws`() = runTest(testDispatcher) {
coEvery { agendaRepo.getUntaggedTaskHeadings() } throws RuntimeException("boom")
coEvery { agendaRepo.getTaskTagsWithCounts() } returns emptyList()
val vm = createViewModel()
advanceUntilIdle()
vm.errorMessage.first() shouldBe "boom"
}todoStates
@Test
fun `todoStates reads from appPreferences`() {
val vm = createViewModel()
vm.todoStates shouldBe listOf("TODO", "NEXT", "DONE")
}TaggedTasksListScreen Composable
The tag list screen: tag name filter bar, untagged tasks section at top, grouped tag sections with counts, and pull-to-refresh.
@Composable
private fun TaggedTasksListScreen(viewModel: TaggedTasksViewModel) {
val untaggedTasks by viewModel.untaggedTasks.collectAsState()
val tags by viewModel.visibleTags.collectAsState()
val tagFilterQuery by viewModel.tagFilterQuery.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val isRefreshing = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
// Tag filter bar
OutlinedTextField(
value = tagFilterQuery,
onValueChange = { viewModel.updateTagFilter(it) },
label = { Text("Filter tags") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)
)
if (isLoading && untaggedTasks.isEmpty() && tags.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (untaggedTasks.isEmpty() && tags.isEmpty()) {
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(
"No tagged tasks found. Tasks with tags appear here grouped by tag.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
} else {
PullToRefreshBox(
isRefreshing = isRefreshing.value,
onRefresh = {
isRefreshing.value = true
viewModel.refresh()
scope.launch {
kotlinx.coroutines.delay(500)
isRefreshing.value = false
}
},
modifier = Modifier.fillMaxSize()
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
// Meta: untagged tasks
if (untaggedTasks.isNotEmpty()) {
item {
Text(
"Untagged (${untaggedTasks.size})",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
items(untaggedTasks, key = { "untagged-${it.file}:${it.position}" }) { task ->
ProjectTaskCard(
task = task,
onClick = { viewModel.openTask(task) }
)
HorizontalDivider()
}
item {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
}
}
// Tag sections
items(tags, key = { "tag-${it.first}" }) { (tag, count) ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.selectTag(tag) }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "#$tag",
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
modifier = Modifier.weight(1f)
)
Text(
text = count.toString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider()
}
}
}
}
}
}Tag Detail
The tag detail view: header with tag name and back button, task card list with TODO state picker.
Tag Selection
selectTag loads tasks for a tag from AgendaRepository into the detail view when a tag is tapped. Passing null clears the detail. Delegation methods for openTask and updateTaskState mirror ProjectViewModel.
fun selectTag(tag: String?) {
_selectedTag.value = tag
if (tag != null) {
viewModelScope.launch {
try {
val tasks = agendaRepository.getTasksByTag(tag)
taskList.loadTasks(tasks)
} catch (e: Exception) {
Log.e(TAG, "selectTag failed for $tag", e)
_errorMessage.value = e.message
}
}
} else {
taskList.clearDetail()
}
}
fun openTask(task: TaskHeading) = taskList.openTask(task)
fun updateTaskState(file: String, position: Int, newState: String?) =
taskList.updateTaskState(file, position, newState)
}selectTag
@Test
fun `selectTag loads tasks for that tag into detail`() = runTest(testDispatcher) {
val tasks = listOf(task(position = 10), task(position = 20))
coEvery { agendaRepo.getUntaggedTaskHeadings() } returns emptyList()
coEvery { agendaRepo.getTaskTagsWithCounts() } returns listOf("work" to 2L)
coEvery { agendaRepo.getTasksByTag("work") } returns tasks
val vm = createViewModel()
advanceUntilIdle()
vm.selectTag("work")
advanceUntilIdle()
vm.selectedTag.first() shouldBe "work"
vm.detailTasks.first() shouldBe tasks
}selectTag null
@Test
fun `selectTag with null clears detail`() = runTest(testDispatcher) {
coEvery { agendaRepo.getUntaggedTaskHeadings() } returns emptyList()
coEvery { agendaRepo.getTaskTagsWithCounts() } returns emptyList()
coEvery { agendaRepo.getTasksByTag(any()) } returns listOf(task())
val vm = createViewModel()
advanceUntilIdle()
vm.selectTag("work")
advanceUntilIdle()
vm.detailTasks.first().isNotEmpty() shouldBe true
vm.selectTag(null)
advanceUntilIdle()
vm.detailTasks.first().isEmpty() shouldBe true
}TaggedTasksDetailScreen Composable
The tag detail screen: header with back button showing =#tag-name=, task card list, and TODO state picker dialog.
@Composable
private fun TaggedTasksDetailScreen(
viewModel: TaggedTasksViewModel,
onBack: () -> Unit
) {
val selectedTag by viewModel.selectedTag.collectAsState()
val tasks by viewModel.detailTasks.collectAsState()
BackHandler { onBack() }
var todoEditTarget by remember { mutableStateOf<Triple<String, Int, String?>?>(null) }
val todoStates = viewModel.todoStates
Column(modifier = Modifier.fillMaxSize()) {
// Header with back button
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text(
text = "#${selectedTag ?: ""}",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 8.dp)
)
}
if (tasks.isEmpty()) {
Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(
"No tasks with this tag.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
Text(
"Tasks (${tasks.size})",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
items(tasks, key = { "${it.file}:${it.position}" }) { task ->
ProjectTaskCard(
task = task,
onClick = { viewModel.openTask(task) },
onTodoClick = { todoEditTarget = Triple(task.file, task.position, task.todo) }
)
HorizontalDivider()
}
}
}
}
TodoStatePickerDialog(
target = todoEditTarget,
todoStates = todoStates,
onDismiss = { todoEditTarget = null },
onSelect = { state ->
val (file, position, _) = todoEditTarget!!
viewModel.updateTaskState(file, position, state)
todoEditTarget = null
},
onClear = {
val (file, position, _) = todoEditTarget!!
viewModel.updateTaskState(file, position, null)
todoEditTarget = null
}
)
}Tangle Targets
Noweb assembly blocks for the five output files.
ProjectViewModel.kt
Preamble (imports + TAG constant) followed by all shared and tab-specific classes in order.
package computer.whatthefuck.arcology.app.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.WorkInfo
import androidx.work.WorkManager
import computer.whatthefuck.arcology.app.cache.OrgDocumentCache
import computer.whatthefuck.arcology.app.data.AppPreferencesInterface
import computer.whatthefuck.arcology.app.worker.IndexingWorker
import computer.whatthefuck.arcology.database.AgendaRepository
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.FileTaskSummary
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.domain.TaskHeading
import computer.whatthefuck.arcology.editor.EditResult
import computer.whatthefuck.arcology.editor.OrgDocumentEditor
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import xyz.lepisma.orgmode.OrgParseResult
private const val TAG = "ProjectViewModel"<<proj-vm-preamble>>
<<proj-nav-event>>
<<tlvm-core>>
<<tlvm-detail>>
<<tlvm-opentask>>
<<tlvm-update>>
<<proj-item>>
<<proj-vm-state>>
<<proj-vm-index>>
<<proj-vm-load>>
<<proj-vm-filters>>
<<proj-vm-retire>>
<<proj-vm-detail>>
<<tt-vm-state>>
<<tt-vm-index>>
<<tt-vm-list>>
<<tt-vm-detail>>ProjectsTabContent.kt
Preamble (package + UI imports) followed by all composable functions.
package computer.whatthefuck.arcology.app.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.app.viewmodel.ProjectItem
import computer.whatthefuck.arcology.app.viewmodel.ProjectNavEvent
import computer.whatthefuck.arcology.app.viewmodel.ProjectViewModel
import computer.whatthefuck.arcology.app.viewmodel.TaggedTasksViewModel
import computer.whatthefuck.arcology.app.ui.components.renderer.OrgDocumentRenderer
import computer.whatthefuck.arcology.app.ui.components.renderer.RenderMode
import computer.whatthefuck.arcology.domain.TaskHeading
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import kotlinx.coroutines.launch
import xyz.lepisma.orgmode.OrgParseResultNow I need ProjectsTabContent top-level composable as a named block:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProjectsTabContent(
viewModel: ProjectViewModel,
onNavigateToNode: (String) -> Unit
) {
val selectedFile by viewModel.selectedFile.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
viewModel.navEvents.collect { event ->
when (event) {
is ProjectNavEvent.OpenNode -> onNavigateToNode(event.nodeId)
}
}
}
LaunchedEffect(viewModel.errorMessage) {
viewModel.errorMessage.collect { msg ->
if (msg != null) {
scope.launch { snackbarHostState.showSnackbar(msg) }
}
}
}
if (selectedFile == null) {
ProjectsListScreen(viewModel = viewModel)
} else {
ProjectDetailScreen(viewModel = viewModel, onBack = { viewModel.selectProject(null) })
}
}Now TaggedTasksScreen top-level:
/**
* Tagged Tasks tab content. Shows untagged tasks as a "meta" section at the
* top, then tag-grouped sections below. Tapping a tag drills into a detail
* screen showing that tag's task list.
*
* Layout: a single screen that switches between the list view and the detail
* view based on [TaggedTasksViewModel.selectedTag].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaggedTasksScreen(
viewModel: TaggedTasksViewModel,
onNavigateToNode: (String) -> Unit
) {
val selectedTag by viewModel.selectedTag.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
viewModel.navEvents.collect { event ->
when (event) {
is ProjectNavEvent.OpenNode -> onNavigateToNode(event.nodeId)
}
}
}
LaunchedEffect(viewModel.errorMessage) {
viewModel.errorMessage.collect { msg ->
if (msg != null) {
scope.launch { snackbarHostState.showSnackbar(msg) }
}
}
}
if (selectedTag == null) {
TaggedTasksListScreen(viewModel = viewModel)
} else {
TaggedTasksDetailScreen(viewModel = viewModel, onBack = { viewModel.selectTag(null) })
}
}<<tab-content-preamble>>
<<proj-tab-content>>
<<proj-list-screen>>
<<proj-card>>
<<proj-detail-screen>>
<<proj-task-card>>
<<todo-picker>>
<<tt-screen>>
<<tt-list-screen>>
<<tt-detail-screen>>ProjectViewModelTest.kt
<<proj-vm-test-prelude>>
<<proj-vm-test>>
<<proj-vm-test-end>>TaskListViewModelTest.kt
<<tlvm-test-prelude>>
<<tlvm-test>>
<<tlvm-test-end>>TaggedTasksViewModelTest.kt
<<tt-vm-test-prelude>>
<<tt-vm-test>>
<<tt-vm-test-end>>Related Modules
agenda/models.org — AgendaRepository, FileTaskSummary, Agenda.sq queries
agenda/screen.org — AgendaScreen (Projects tab wiring)
editor/renderer.org — OrgDocumentRenderer (used in project detail view)
app/data.org — OrgDocumentCache (file parsing)
roam/models.org — RoamRepository (file-level node + tag resolution)