Arcology Engine

Projects Screen

Contents

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.

kotlin#+name: tlvm-core
/**
 * 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.

kotlin#+name: tlvm-detail
    /**
     * 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.

kotlin#+name: tlvm-opentask
    /**
     * 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.

kotlin#+name: tlvm-update
    /**
     * 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

kotlin#+name: tlvm-test-prelude
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()
        )
    }
kotlin#+name: tlvm-test-end
}

loadTasks

kotlin#+name: tlvm-test:noweb-ref tlvm-test
    @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

kotlin#+name: tlvm-test:noweb-ref tlvm-test
    @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

kotlin#+name: tlvm-test:noweb-ref tlvm-test
    @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

kotlin#+name: tlvm-test:noweb-ref tlvm-test
    @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

kotlin#+name: tlvm-test:noweb-ref tlvm-test
    @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

kotlin#+name: tlvm-test:noweb-ref tlvm-test
    @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.

kotlin#+name: proj-item
/**
 * 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.

kotlin#+name: proj-nav-event
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.

kotlin#+name: proj-vm-state
/**
 * 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.

kotlin#+name: proj-vm-index
    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.

kotlin#+name: proj-vm-load
    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.

kotlin#+name: proj-vm-filters
    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.

kotlin#+name: proj-vm-retire
    /**
     * 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.

kotlin#+name: proj-vm-test-prelude
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
        )
    }
kotlin#+name: proj-vm-test-end
}

stuck detection

kotlin#+name: proj-vm-test:noweb-ref proj-vm-test
    @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

kotlin#+name: proj-vm-test:noweb-ref proj-vm-test
    @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

kotlin#+name: proj-vm-test:noweb-ref proj-vm-test
    @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

kotlin#+name: proj-vm-test:noweb-ref proj-vm-test
    @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

kotlin#+name: proj-vm-test:noweb-ref proj-vm-test
    @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

kotlin#+name: proj-vm-test:noweb-ref proj-vm-test
    @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

kotlin#+name: proj-vm-test:noweb-ref proj-vm-test
    @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

kotlin#+name: proj-vm-test:noweb-ref proj-vm-test
    @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

kotlin#+name: proj-vm-test:noweb-ref proj-vm-test
    @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

kotlin#+name: proj-vm-test:noweb-ref proj-vm-test
    @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

kotlin#+name: proj-vm-test:noweb-ref proj-vm-test
    @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.

kotlin#+name: proj-list-screen
@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.

kotlin#+name: proj-card
@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.

kotlin#+name: proj-vm-detail
    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.

kotlin#+name: proj-detail-screen
@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

kotlin#+name: proj-task-card
@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.

kotlin#+name: todo-picker
/**
 * 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.

kotlin#+name: tt-vm-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.todoStates

Indexing Observation

Observes the indexing worker for auto-refresh on indexing completion.

kotlin#+name: tt-vm-index
    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.

kotlin#+name: tt-vm-list
    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

kotlin#+name: tt-vm-test-prelude
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()
        )
    }
kotlin#+name: tt-vm-test-end
}

loadData

kotlin#+name: tt-vm-test:noweb-ref tt-vm-test
    @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

kotlin#+name: tt-vm-test:noweb-ref tt-vm-test
    @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

kotlin#+name: tt-vm-test:noweb-ref tt-vm-test
    @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.

kotlin#+name: tt-list-screen
@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.

kotlin#+name: tt-vm-detail
    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

kotlin#+name: tt-vm-test:noweb-ref tt-vm-test
    @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

kotlin#+name: tt-vm-test:noweb-ref tt-vm-test
    @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.

kotlin#+name: tt-detail-screen
@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.

kotlin#+name: proj-vm-preamble
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"
kotlin#+name: proj-vm-assembly:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/viewmodel/ProjectViewModel.kt:noweb yes
<<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.

kotlin#+name: tab-content-preamble
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.OrgParseResult

Now I need ProjectsTabContent top-level composable as a named block:

kotlin#+name: proj-tab-content
@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:

kotlin#+name: tt-screen
/**
 * 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) })
    }
}
kotlin#+name: tab-content-assembly:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/screens/ProjectsTabContent.kt:noweb yes
<<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

kotlin:tangle ../app/src/test/kotlin/computer/whatthefuck/arcology/app/viewmodel/ProjectViewModelTest.kt:noweb yes
<<proj-vm-test-prelude>>

<<proj-vm-test>>

<<proj-vm-test-end>>

TaskListViewModelTest.kt

kotlin:tangle ../app/src/test/kotlin/computer/whatthefuck/arcology/app/viewmodel/TaskListViewModelTest.kt:noweb yes
<<tlvm-test-prelude>>

<<tlvm-test>>

<<tlvm-test-end>>

TaggedTasksViewModelTest.kt

kotlin:tangle ../app/src/test/kotlin/computer/whatthefuck/arcology/app/viewmodel/TaggedTasksViewModelTest.kt:noweb yes
<<tt-vm-test-prelude>>

<<tt-vm-test>>

<<tt-vm-test-end>>

Related Modules