Arcology Engine

Org Document Editor Screen

Contents

OrgDocumentEditorScreen is the central screen of the Arcology mobile app. It serves two primary user flows:

  1. Capture — creating a new note via inline routing in MainActivity, starting in edit mode with template support

  2. Read & Explore — browsing an existing node from search/navigation, starting in read-only mode with folding, refile, and ID-creation actions

The screen is a modal interface: ReadOnlyView and EditingView share the same OrgDocumentEditorViewModel and toggle via EditorDisplayMode.

OrgDocumentEditorScreen

The scaffold composable that coordinates the entire editor interface. It manages the display mode switch, renders either ReadOnlyView or EditingView based on mode, handles the FAB menu (Edit/Refile in read-only mode), and wraps the entire hierarchy with location permission launchers, file picker launchers, and dialog overlays (template picker, link picker, location picker, refile picker, prompt dialogs).

This composable contains all the boilerplate state management for the editing session — tracking the current file system, handling file reload after refile or ID creation, and maintaining floating toolbar state (show/hide on text selection).

Design: Single shared ViewModel

Rather than creating separate ViewModels for read vs. edit mode, this screen uses a single OrgDocumentEditorViewModel with mutable displayMode state. This mirrors the author's Emacs workflow where editing is a mode switch (like evil-mode insert) rather than navigating to a separate screen. The trade-off is a larger ViewModel, but we save on duplication of parsing logic and state synchronization.

Design: FAB menu pattern

In read-only mode, an expanding FAB menu exposes the two most common actions without cluttering the top bar: Edit (switches to editing mode) and Refile (opens NodePickerScreen to select a target). The main Add FAB toggles the menu, with Edit and Refile stacked vertically above.

Design: Formatting toolbar on selection

When text is selected in EditingView, a floating toolbar appears at the bottom with single-character buttons (*, /, =, ~, Q, L, {{C}}). The {{C}} button is only visible when flashcard type is CLOZE. The toolbar uses TextButton composables for keyboard-like compactness and wraps selections with the appropriate org-mode markup.

ReadOnlyView

The AST-rendered read mode that displays the full document with OrgDocumentRenderer. It shows parsing state (Loading/Ready/Error), selects the renderer based on mode (RenderMode.FULL_DOCUMENT), renders existing attachments (with ImageRenderer for images), and displays the collapsible BacklinksPanel at the bottom.

This composable receives callbacks for heading long-press actions (onHeadingRefile, onHeadingCreateId), which trigger dialogs that wrap NodePickerScreen or ID generation.

Design: Single pre-parsed AST

Rather than re-parsing on every render, ReadOnlyView receives a ParseState from the ViewModel which holds the AST result (OrgParseResult). The ViewModel updates this state whenever fullContent changes, parsing on Dispatchers.Default via OrgDocumentCache. This means navigating back to the same unmodified file is instant (cache hit).

Design: Attachment type branching

Attachments in the properties drawer are rendered differently by type: IMAGE types get the full ImageRenderer with async loading and error handling; VIDEO and FILE types get placeholder surface cards showing the type and filename. This reflects the current limitation where only image inline rendering is implemented.

EditingView

The full editing interface containing OrgBodyEditor, a TODO dropdown, a Properties inspector list, and the Save button with loading/saved/error states.

All metadata controls are "shortcuts" that splice data into the text via the ViewModel's rebuildHeadingLine and rebuildPropertiesDrawer methods. The user can also type raw org-mode syntax directly in the text field.

Design: Single text field for entire node

Unlike a structured form where title, tags, and body are separate fields, this composable presents the entire org node (heading line + properties drawer + body) as one OrgBodyEditor text field. The metadata controls are helper UIs, but the user can always edit the markup directly. The document is the source of truth.

Design: Properties inspector + Add-property menu (no chips)

The earlier design placed ~10 metadata chips (Node, Tags, Aliases, Refs, Location, Attach, Sched, Due, Card, Publish) in a single FlowRow, with inline AnimatedVisibility expanded sections. With configured TODO states the surface reached ~14–20 controls competing for attention.

The redesign replaces every configuration chip with an org-style Properties inspector:

  • A key/value row list (:TAGS:, :SCHEDULED:, :ARCOLOGY_KEY:, ...) renders only for fields that are set. An empty capture shows no rows; a complex node shows a readable drawer. State-at-a-glance without a wall of toggle controls.

  • Each row taps to open that field's editor in a ModalBottomSheet (hosting the existing PlanningEditorSection, PublishSection, LocationPickerScreen, attachment picker, etc. verbatim); a trailing ✕ clears the field.

  • An + Add property ▾ row anchors a grouped DropdownMenu (Identity Content Time & Place Learning Publishing) listing only the unset fields. Choosing one opens that field's sheet.

  • The TODO selector becomes a single ExposedDropdownMenuBox row (TODO: TODO ▾) instead of an N-chip FlowRow.

This mirrors how org-mode itself displays configuration (the properties drawer + planning line), maps cleanly on to the keyboard-driven future ("Add property" becomes a searchable command picker), and keeps the ViewModel API untouched.

TemplateBottomSheet

A modal bottom sheet that displays the list of capture templates from appPreferences.captureTemplates. It shows template names and pattern snippets, highlights the selected template with a check mark, and triggers viewModel.selectTemplate() on tap.

This composable is triggered by the template button in EditingView header (showing the current template name or "Template" if none selected).

PromptDialog

An AlertDialog that prompts the user for values missing from template placeholders (%^{prompt}). It displays one OutlinedTextField per requested prompt and returns a map of label → value to the ViewModel via resolvePrompts().

This dialog appears after template selection when the template contains %^{...} escapes. Prompt labels are extracted by TemplateExpander.expand().

PropertiesInspectorList

The composable that renders the set of configured node fields as a key/value list, replacing the former chip row. For each set field it emits a PropertyRow (label + value + trailing clear ✕); rows are derived from the ViewModel's StateFlows (tags, aliases, refs, createId, scheduled, deadline, geoCoords, attachments, flashcardType, arcologyKey + its sub-properties).

A section header "Properties" is shown only when at least one row is present. Below the rows, an + Add property ▾ row anchors AddPropertyMenu, a DropdownMenu with dividers grouping the unset fields so the user can only add things they haven't already configured. Choosing an item invokes the onOpenSheet callback with the relevant FieldSheet variant.

AddPropertyMenu

A DropdownMenu anchored to the + Add property ▾ row. It lists the fields not currently set, grouped under headers with HorizontalDivider separators:

  • Identity — Node ID, Aliases, Refs

  • Content — Tags, Attachments

  • Time & Place — Scheduled, Deadline, Location

  • Learning — Flashcard

  • Publishing — Publish (ARCOLOGY_KEY)

Each entry is a DropdownMenuItem whose onClick opens the matching field-editor sheet via onSelect(FieldSheet). Fields already set are omitted so the menu scales down as the node fills up.

PublishChipSection

The Arcology publishing UI: a "Publish" expandable chip (globe icon) with an expanded section for ARCOLOGY_KEY and, once the key is set, the optional publishing properties read by Arcology's Web Server:

  • ARCOLOGY_KEY — a plain text field for the SITE/path route key; setting it toggles the publish state on

  • ARCOLOGY_EXPIRE — a date picker plus optional time chip (same interaction pattern as PlanningEditorSection), emitting an inactive org timestamp

  • ARCOLOGY_ALLOW_CRAWL — a checkbox row emitting t / nil

  • ARCOLOGY_PAGE_TEMPLATE — an exposed dropdown offering the templates the web server ships: page, wide, topic, app

Unlike the other metadata chips, the publish section is not hidden behind a confirm button — every edit splices straight into the properties drawer via the ViewModel, and on EditExisting saves the properties are persisted with editor.setProperty(). Toggling the chip off removes only :ARCOLOGY_KEY:; expire/crawl/template choices survive so re-publishing is fast.

AttachmentPreviewCard

A Card component that displays pending attachments queued for save. It shows the attachment type icon (=Image/=Video/=AttachFile=), filename, and MIME type. A remove button in the corner discards the attachment via viewModel.removeAttachment().

These preview cards appear in the attachments expanded section of EditingView before save. After save, the handleAttachments() method copies them to the data/<id>/<remainder>/<id> directory structure.

FieldSheet

An enum describing which field-editor sheet is currently open (or null when none). EditingView holds a single var sheet by remember { mutableStateOf<FieldSheet?>(null) } and renders the corresponding ModalBottomSheet wrapper. Each sheet hosts an existing editor verbatim:

  • Tags Aliases RefsOutlinedTextField + AssistChip list (lifted from the former inline sections).

  • Scheduled / DeadlinePlanningEditorSection.

  • LocationLocationPickerScreen.

  • AttachmentsAttachmentPreviewCard list + "Add Attachment" Button driving the file picker launcher.

  • Flashcard — an explicit type list (replacing the tap-to-cycle FlashcardTypeChip) plus the cloze-subtype dropdown.

  • PublishPublishSection (ARCOLOGY_KEY, expire, allow-crawl, page template).

Hosting every field editor in a ModalBottomSheet (rather than inline AnimatedVisibility sections) keeps the capture surface minimal and gives each field a focused, consistent editing context.

Source Code

The complete Kotlin source file containing all 8 composables above.

computer.whatthefuck.arcology.app.ui.screens.OrgDocumentEditorScreen

kotlin:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/screens/OrgDocumentEditorScreen.kt
package computer.whatthefuck.arcology.app.ui.screens

import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.rememberScrollState
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import computer.whatthefuck.arcology.app.data.AppPreferences
import computer.whatthefuck.arcology.app.data.LocationService
import computer.whatthefuck.arcology.app.ui.components.BacklinkItem
import computer.whatthefuck.arcology.app.ui.components.BacklinksPanel
import computer.whatthefuck.arcology.app.ui.components.ContentMatchItem
import computer.whatthefuck.arcology.app.ui.components.ImageRenderer
import computer.whatthefuck.arcology.app.ui.components.NodeBreadcrumb
import computer.whatthefuck.arcology.app.ui.components.OrgBodyEditor
import computer.whatthefuck.arcology.app.ui.theme.VulfMono
import computer.whatthefuck.arcology.app.ui.theme.VulfMonoLightItalic
import computer.whatthefuck.arcology.app.ui.components.renderer.OrgDocumentRenderer
import computer.whatthefuck.arcology.app.ui.components.renderer.RenderMode
import computer.whatthefuck.arcology.app.ui.components.renderer.ClozeState as RendererClozeState
import computer.whatthefuck.arcology.app.viewmodel.DocumentEditMode
import computer.whatthefuck.arcology.app.viewmodel.EditorDisplayMode
import computer.whatthefuck.arcology.app.viewmodel.EditorState
import computer.whatthefuck.arcology.app.viewmodel.OrgDocumentEditorViewModel
import computer.whatthefuck.arcology.app.viewmodel.PerNodeMetadata
import computer.whatthefuck.arcology.app.viewmodel.SaveAction
import computer.whatthefuck.arcology.domain.AttachmentType
import computer.whatthefuck.arcology.domain.ClozeType
import computer.whatthefuck.arcology.domain.FlashcardType
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.domain.PendingAttachment
import computer.whatthefuck.arcology.domain.createPendingAttachment
import computer.whatthefuck.arcology.editor.PlanningInfoUtils
import computer.whatthefuck.arcology.editor.PlanningKind
import computer.whatthefuck.arcology.flashcard.ClozeService
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import computer.whatthefuck.arcology.search.SearchService
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import xyz.lepisma.orgmode.OrgParseResult

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OrgDocumentEditorScreen(
    viewModel: OrgDocumentEditorViewModel = koinViewModel(),
    onSaved: () -> Unit = {},
    onNavigateToNode: (nodeId: String) -> Unit = {},
    onTagClick: (tag: String) -> Unit = {}
) {
    val searchService: SearchService = koinInject()
    val locationService: LocationService = koinInject()
    val appPreferences: AppPreferences = koinInject()
    val context = LocalContext.current
    val scope = rememberCoroutineScope()

    val fullContent by viewModel.fullContent.collectAsState()
    val tags by viewModel.tags.collectAsState()
    val refs by viewModel.refs.collectAsState()
    val childMetadata by viewModel.childMetadata.collectAsState()
    val captureTemplates by viewModel.captureTemplates.collectAsState()
    val selectedTemplateId by viewModel.selectedTemplateId.collectAsState()
    val pendingPrompts by viewModel.pendingPrompts.collectAsState()
    val geoCoords by viewModel.geoCoords.collectAsState()

    // Display mode state
    val displayMode by viewModel.displayMode.collectAsState()
    val node by viewModel.node.collectAsState()

    // Breadcrumbs state
    val parentNodes by viewModel.parentNodes.collectAsState()

    // Backlinks and content matches state
    val backlinks by viewModel.backlinks.collectAsState()
    val contentMatches by viewModel.contentMatches.collectAsState()
    val backlinksExpanded by viewModel.backlinksExpanded.collectAsState()
    val existingAttachments by viewModel.existingAttachments.collectAsState()

    // Location capture state
    var captureLocationEnabled by remember { mutableStateOf(appPreferences.isCaptureLocationEnabled()) }
    var hasLocationPermission by remember {
        mutableStateOf(locationService.hasLocationPermission())
    }

    // Permission launcher
    val locationPermissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        hasLocationPermission = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
                               permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
        if (hasLocationPermission && captureLocationEnabled) {
            scope.launch {
                val location = locationService.getCurrentLocation()
                viewModel.updateGeoCoords(location)
            }
        }
    }

    // Fetch location when capture location is enabled and we have permission
    LaunchedEffect(captureLocationEnabled, hasLocationPermission) {
        if (captureLocationEnabled && hasLocationPermission) {
            val location = locationService.getCurrentLocation()
            viewModel.updateGeoCoords(location)
        } else if (!captureLocationEnabled) {
            viewModel.updateGeoCoords(null)
        }
    }

    var showTemplateSheet by remember { mutableStateOf(false) }
    var showLocationPicker by remember { mutableStateOf(false) }

    // FAB menu state
    var fabExpanded by remember { mutableStateOf(false) }
    var headingRefileSourceId by remember { mutableStateOf<String?>(null) }
    var showLinkPicker by remember { mutableStateOf(false) }
    var pendingLinkSelection by remember { mutableStateOf<Pair<Int, Int>?>(null) }
    var lastRefiledNodeId by remember { mutableStateOf<String?>(null) }

    // Pending refile triggered by save-and-refile action
    val pendingRefileNodeId by viewModel.pendingRefileNodeId.collectAsState()
    LaunchedEffect(pendingRefileNodeId) {
        if (pendingRefileNodeId != null) {
            headingRefileSourceId = pendingRefileNodeId
            viewModel.clearPendingRefile()
        }
    }

    // Reload file content after refile or ID creation
    fun reloadFileContent() {
        val treeUri = appPreferences.getSelectedDirectoryUri()
        if (treeUri != null) {
            val fs = AndroidFileSystem(context, treeUri)
            viewModel.loadFileContent(fs)
        }
    }

    LaunchedEffect(lastRefiledNodeId) {
        if (lastRefiledNodeId != null) {
            reloadFileContent()
            lastRefiledNodeId = null
        }
    }

    // Track the full content as TextFieldValue for cursor position
    var fullContentFieldValue by remember {
        mutableStateOf(fullContent)
    }
    LaunchedEffect(fullContent) {
        if (fullContent.text != fullContentFieldValue.text) {
            fullContentFieldValue = fullContent
        }
    }

    val snackbarHostState = remember { SnackbarHostState() }

    // Show snackbar and reset after saved using SharedFlow to avoid StateFlow conflation
    LaunchedEffect(Unit) {
        viewModel.saveCompleted.collect {
            onSaved()
            viewModel.resetState()
            snackbarHostState.showSnackbar(
                message = "Saved",
                duration = SnackbarDuration.Short
            )
        }
    }

    // File picker launcher for attachments
    val filePickerLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.OpenMultipleDocuments()
    ) { uris: List<Uri> ->
        scope.launch {
            uris.forEach { uri ->
                try {
                    context.contentResolver.takePersistableUriPermission(
                        uri,
                        Intent.FLAG_GRANT_READ_URI_PERMISSION
                    )
                } catch (e: Exception) {
                    // Permission already granted or not needed
                }

                val attachment = context.createPendingAttachment(uri)
                viewModel.addAttachment(attachment)
            }
        }
    }

    // Load file content when opening existing node
    val editMode by viewModel.editMode.collectAsState()
    LaunchedEffect(editMode, context) {
        if (editMode is DocumentEditMode.EditExisting) {
            val treeUri = appPreferences.getSelectedDirectoryUri()
            if (treeUri != null) {
                val fs = AndroidFileSystem(context, treeUri)
                viewModel.loadFileContent(fs)
            }
        }
    }

    val outerScrollState = rememberScrollState()

    Scaffold(
        snackbarHost = { SnackbarHost(snackbarHostState) },
        topBar = {
            when (displayMode) {
                EditorDisplayMode.ReadOnly -> {
                    if (parentNodes.isNotEmpty()) {
                        NodeBreadcrumb(
                            parents = parentNodes,
                            currentTitle = node?.title ?: "Untitled",
                            onParentClick = onNavigateToNode
                        )
                    }
                }
                EditorDisplayMode.Editing -> {
                    EditingTopBar(
                        captureTemplates = captureTemplates,
                        selectedTemplateId = selectedTemplateId,
                        onShowTemplateSheet = { showTemplateSheet = true }
                    )
                }
            }
        },
        floatingActionButton = {
            if (displayMode == EditorDisplayMode.ReadOnly) {
                Box {
                    // Main FAB button - toggles menu
                    FloatingActionButton(
                        onClick = { fabExpanded = !fabExpanded },
                        modifier = Modifier.offset(y = 0.dp)
                    ) {
                        Icon(
                            imageVector = Icons.Default.Add,
                            contentDescription = "More options"
                        )
                    }

                    // Edit FAB - only shown when expanded
                    if (fabExpanded) {
                        FloatingActionButton(
                            onClick = {
                                fabExpanded = false
                                viewModel.enterEditingMode()
                            },
                            modifier = Modifier.offset(y = (-64).dp)
                        ) {
                            Icon(
                                imageVector = Icons.Default.Edit,
                                contentDescription = "Edit"
                            )
                        }
                    }

                    // Refile FAB - only shown when expanded
                    if (fabExpanded) {
                        FloatingActionButton(
                            onClick = {
                                fabExpanded = false
                                headingRefileSourceId = node?.id
                            },
                            modifier = Modifier.offset(y = (-128).dp)
                        ) {
                            Icon(
                                imageVector = Icons.Default.Share,
                                contentDescription = "Refile"
                            )
                        }
                    }
                }
            }
        }
    ) { paddingValues ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .imePadding()
        ) {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .verticalScroll(outerScrollState)
                    .padding(horizontal = 16.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                // Display mode switcher
                when (displayMode) {
                    EditorDisplayMode.ReadOnly -> {
                        ReadOnlyView(
                            node = node,
                            fullContent = fullContent,
                            parseState = viewModel.parseState.collectAsState().value,
                            backlinks = backlinks,
                            contentMatches = contentMatches,
                            backlinksExpanded = backlinksExpanded,
                            onBacklinksExpandedChange = { viewModel.toggleBacklinksExpanded() },
                            onNavigateToNode = onNavigateToNode,
                            existingAttachments = existingAttachments,
                            fileSystem = viewModel.fileSystem,
                            onHeadingRefile = { headingId -> headingRefileSourceId = headingId },
                            onHeadingCreateId = { section ->
                                viewModel.createIdForHeading(section, context) {
                                    reloadFileContent()
                                }
                            },
                            tags = tags,
                            refs = refs,
                            onTagClick = onTagClick,
                            flashcardType = viewModel.flashcardType.collectAsState().value,
                            flashcardPositions = viewModel.flashcardPositions.collectAsState().value,
                            reviewHistory = viewModel.reviewHistory.collectAsState().value,
                            childMetadata = childMetadata
                        )
                    }
                    EditorDisplayMode.Editing -> {
                        EditingView(
                            viewModel = viewModel,
                            searchService = searchService,
                            locationService = locationService,
                            appPreferences = appPreferences,
                            context = context,
                            scope = scope,
                            fullContentFieldValue = fullContentFieldValue,
                            captureLocationEnabled = captureLocationEnabled,
                            hasLocationPermission = hasLocationPermission,
                            showLocationPicker = showLocationPicker,
                            snackbarHostState = snackbarHostState,
                            onSaved = onSaved,
                            locationPermissionLauncher = locationPermissionLauncher,
                            filePickerLauncher = filePickerLauncher,
                            showLocationPickerChanged = { showLocationPicker = it },
                            captureLocationEnabledChanged = { captureLocationEnabled = it },
                            fullContentFieldValueChanged = { fullContentFieldValue = it },
                            outerScrollState = outerScrollState
                        )
                    }
                }

                // Template selection bottom sheet
                if (showTemplateSheet) {
                    TemplateBottomSheet(
                        templates = captureTemplates,
                        selectedId = selectedTemplateId,
                        onSelect = { template ->
                            showTemplateSheet = false
                            viewModel.selectTemplate(template.id)
                        },
                        onDismiss = { showTemplateSheet = false }
                    )
                }

                // Prompt dialog for %^{...} escapes
                if (pendingPrompts.isNotEmpty()) {
                    PromptDialog(
                        prompts = pendingPrompts,
                        onConfirm = { responses ->
                            viewModel.resolvePrompts(responses)
                        },
                        onDismiss = {
                            viewModel.clearTemplate()
                        }
                    )
                }

                // Location picker dialog
                if (showLocationPicker) {
                    Dialog(
                        onDismissRequest = { showLocationPicker = false },
                        properties = DialogProperties(
                            usePlatformDefaultWidth = false,
                            decorFitsSystemWindows = false
                        )
                    ) {
                        LocationPickerScreen(
                            initialLocation = geoCoords,
                            onLocationSelected = { selectedCoords ->
                                showLocationPicker = false
                                viewModel.updateGeoCoords(selectedCoords)
                                viewModel.updateCreateId(true)
                            },
                            onDismiss = { showLocationPicker = false }
                        )
                    }
                }

        // Refile picker dialog (unified: FAB or heading long-press)
        if (headingRefileSourceId != null) {
            val sourceId = headingRefileSourceId!!
            Dialog(
                onDismissRequest = { headingRefileSourceId = null },
                properties = DialogProperties(
                    usePlatformDefaultWidth = false,
                    decorFitsSystemWindows = false
                )
            ) {
                NodePickerScreen(
                    searchService = searchService,
                    currentNodeId = node?.id ?: "",
                    title = "Refile to...",
                    onNodeSelected = { targetNode ->
                        headingRefileSourceId = null
                        viewModel.refileHeadingToNode(context, sourceId, targetNode) {
                            lastRefiledNodeId = sourceId
                        }
                    },
                    onDismiss = { headingRefileSourceId = null }
                )
            }
        }

        // Link picker dialog
        if (showLinkPicker) {
            Dialog(
                onDismissRequest = {
                    showLinkPicker = false
                    pendingLinkSelection = null
                },
                properties = DialogProperties(
                    usePlatformDefaultWidth = false,
                    decorFitsSystemWindows = false
                )
            ) {
                NodePickerScreen(
                    searchService = searchService,
                    currentNodeId = node?.id ?: "",
                    initialQuery = pendingLinkSelection?.let { (start, end) -> fullContentFieldValue.text.substring(start, end) } ?: "",
                    title = "Link to...",
                    onNodeSelected = { targetNode ->
                        showLinkPicker = false
                        pendingLinkSelection?.let { (start, end) ->
                            val text = fullContentFieldValue.text
                            val selected = text.substring(start, end)
                            val linkText = "[[id:${targetNode.id}][$selected]]"
                            val newText = text.substring(0, start) + linkText + text.substring(end)
                            val newValue = TextFieldValue(
                                text = newText,
                                selection = TextRange(start + linkText.length)
                            )
                            fullContentFieldValue = newValue
                            viewModel.updateFullContent(newValue)
                        }
                        pendingLinkSelection = null
                    },
                    onDismiss = {
                        showLinkPicker = false
                        pendingLinkSelection = null
                    }
                )
            }
        }
            }

            // Floating formatting toolbar when text is selected
            val hasSelection = fullContentFieldValue.selection.start != fullContentFieldValue.selection.end
            if (displayMode == EditorDisplayMode.Editing && hasSelection) {
                val wrapSelection: (String, String) -> Unit = { prefix, suffix ->
                    val start = fullContentFieldValue.selection.min
                    val end = fullContentFieldValue.selection.max
                    if (start != end) {
                        val text = fullContentFieldValue.text
                        val selected = text.substring(start, end)
                        val wrapped = prefix + selected + suffix
                        val newText = text.substring(0, start) + wrapped + text.substring(end)
                        val newValue = TextFieldValue(
                            text = newText,
                            selection = androidx.compose.ui.text.TextRange(start + wrapped.length)
                        )
                        fullContentFieldValue = newValue
                        viewModel.updateFullContent(newValue)
                    }
                }
                Card(
                    modifier = Modifier
                        .align(Alignment.BottomCenter)
                        .padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
                        .wrapContentWidth(),
                    elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
                ) {
                    Row(
                        modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
                        horizontalArrangement = Arrangement.SpaceEvenly,
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        TextButton(onClick = { wrapSelection("*", "*") }) {
                            Text("*", fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)
                        }
                        TextButton(onClick = { wrapSelection("/", "/") }) {
                            Text("/", fontStyle = androidx.compose.ui.text.font.FontStyle.Italic)
                        }
                        TextButton(onClick = { wrapSelection("=", "=") }) {
                            Text("=")
                        }
                        TextButton(onClick = { wrapSelection("~", "~") }) {
                            Text("~")
                        }
                        TextButton(onClick = { wrapSelection("#+BEGIN_QUOTE\n", "\n#+END_QUOTE") }) {
                            Text("Q")
                        }
                        TextButton(onClick = {
                            val start = fullContentFieldValue.selection.min
                            val end = fullContentFieldValue.selection.max
                            if (start != end) {
                                pendingLinkSelection = start to end
                                showLinkPicker = true
                            }
                        }) {
                            Text("[[L]]")
                        }
                        // Cloze button - only shown when in CLOZE mode
                        val flashcardType = viewModel.flashcardType.collectAsState().value
                        if (flashcardType == FlashcardType.CLOZE) {
                            TextButton(onClick = {
                                val start = fullContentFieldValue.selection.min
                                val end = fullContentFieldValue.selection.max
                                if (start != end) {
                                    val text = fullContentFieldValue.text
                                    val nextHoleId = ClozeService().findMaxHoleId(text) + 1
                                    val wrapped = "{{" + text.substring(start, end) + "}@$nextHoleId}"
                                    val newText = text.substring(0, start) + wrapped + text.substring(end)
                                    val newValue = TextFieldValue(
                                        text = newText,
                                        selection = androidx.compose.ui.text.TextRange(start + wrapped.length)
                                    )
                                    fullContentFieldValue = newValue
                                    viewModel.updateFullContent(newValue)
                                }
                            }) {
                                Text("{{C}}")
                            }
                        }
                    }
                }
            }
        }
    }
}

/**
 *,* Read-only view for a node with content renderer.
 ,* Breadcrumbs and edit button have been moved outside this component.
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReadOnlyView(
    node: OrgNode?,
    fullContent: TextFieldValue,
    parseState: OrgDocumentEditorViewModel.ParseState,
    backlinks: List<BacklinkItem>,
    contentMatches: List<ContentMatchItem>,
    backlinksExpanded: Boolean,
    onBacklinksExpandedChange: (Boolean) -> Unit,
    onNavigateToNode: (nodeId: String) -> Unit,
    existingAttachments: List<computer.whatthefuck.arcology.domain.OrgAttachment>,
    fileSystem: AndroidFileSystem?,
    onHeadingRefile: (nodeId: String) -> Unit,
    onHeadingCreateId: (xyz.lepisma.orgmode.OrgSection) -> Unit,
    onTagClick: (tag: String) -> Unit = {},
    tags: List<String> = emptyList(),
    refs: List<String> = emptyList(),
    flashcardType: computer.whatthefuck.arcology.domain.FlashcardType? = null,
    flashcardPositions: List<computer.whatthefuck.arcology.domain.FlashcardPosition> = emptyList(),
    reviewHistory: Map<String, List<computer.whatthefuck.arcology.domain.FlashcardReview>> = emptyMap(),
    childMetadata: Map<String, PerNodeMetadata> = emptyMap()
) {
    Column(
        modifier = Modifier
            .fillMaxWidth(),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        // Content renderer - using pre-parsed AST from ViewModel (off main thread + cache)
        when (parseState) {
            is OrgDocumentEditorViewModel.ParseState.Loading -> {
                Box(
                    modifier = Modifier.fillMaxWidth(),
                    contentAlignment = Alignment.Center
                ) {
                    CircularProgressIndicator(modifier = Modifier.padding(24.dp))
                }
            }
            is OrgDocumentEditorViewModel.ParseState.Ready -> {
                when (val parseResult = parseState.result) {
                    is OrgParseResult.Success -> {
                        SelectionContainer {
                            OrgDocumentRenderer(
                                document = parseResult.document,
                                mode = RenderMode.FULL_DOCUMENT,
                                onLinkClick = onNavigateToNode,
                                modifier = Modifier.fillMaxWidth(),
                                onHeadingOpen = onNavigateToNode,
                                onHeadingRefile = onHeadingRefile,
                                onHeadingCreateId = onHeadingCreateId,
                                nodeId = node?.id,
                                fileSystem = fileSystem,
                                fileUri = node?.file,
                                orgRoamRoot = "",
                                tags = tags,
                                refs = refs,
                                onTagClick = onTagClick,
                                flashcardType = flashcardType,
                                flashcardPositions = flashcardPositions,
                                reviewHistory = reviewHistory,
                                childMetadata = childMetadata
                            )
                        }
                    }
                    is OrgParseResult.Failure -> {
                        android.util.Log.e("OrgDocumentEditorScreen",
                            "Parse failed: ${parseResult.error} at char position ${parseResult.position}")
                        Text(
                            text = fullContent.text,
                            style = MaterialTheme.typography.bodyMedium,
                            modifier = Modifier.fillMaxWidth()
                        )
                    }
                }
            }
            else -> {
                // Idle - nothing to render yet (node not loaded)
            }
        }

        // Attachments section
        if (existingAttachments.isNotEmpty() && fileSystem != null) {
            Column(
                modifier = Modifier.fillMaxWidth(),
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                Text(
                    text = "Attachments (${existingAttachments.size})",
                    style = MaterialTheme.typography.titleSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
                existingAttachments.forEach { attachment ->
                    when (attachment.type) {
                        computer.whatthefuck.arcology.domain.AttachmentType.IMAGE -> {
                            ImageRenderer(
                                attachment = attachment,
                                fileSystem = fileSystem,
                                modifier = Modifier.fillMaxWidth()
                            )
                        }
                        else -> {
                            // For non-image attachments, show a placeholder
                            Surface(
                                modifier = Modifier.fillMaxWidth(),
                                color = MaterialTheme.colorScheme.surfaceVariant,
                                shape = RoundedCornerShape(4.dp)
                            ) {
                                Text(
                                    text = "${attachment.type.name}: ${attachment.resolvedPath.substringAfterLast("/")}",
                                    style = MaterialTheme.typography.bodySmall,
                                    modifier = Modifier.padding(8.dp)
                                )
                            }
                        }
                    }
                }
            }
        }

        // Backlinks panel
        BacklinksPanel(
            backlinks = backlinks,
            contentMatches = contentMatches,
            expanded = backlinksExpanded,
            onExpandedChange = onBacklinksExpandedChange,
            onBacklinkClick = onNavigateToNode,
            modifier = Modifier.fillMaxWidth()
        )
    }
}

/**
 ,* Editing view: text editor + TODO dropdown + Properties inspector + Save.
 ,* Replaces the former 10-chip FlowRow + inline expanded sections.
 ,*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun EditingView(
    viewModel: OrgDocumentEditorViewModel,
    searchService: SearchService,
    locationService: LocationService,
    appPreferences: AppPreferences,
    context: android.content.Context,
    scope: kotlinx.coroutines.CoroutineScope,
    fullContentFieldValue: TextFieldValue,
    captureLocationEnabled: Boolean,
    hasLocationPermission: Boolean,
    showLocationPicker: Boolean,
    snackbarHostState: SnackbarHostState,
    onSaved: () -> Unit,
    locationPermissionLauncher: androidx.activity.compose.ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>,
    filePickerLauncher: androidx.activity.compose.ManagedActivityResultLauncher<Array<String>, List<Uri>>,
    showLocationPickerChanged: (Boolean) -> Unit,
    captureLocationEnabledChanged: (Boolean) -> Unit,
    fullContentFieldValueChanged: (TextFieldValue) -> Unit,
    outerScrollState: androidx.compose.foundation.ScrollState
) {
    val todoState by viewModel.todoState.collectAsState()
    val tags by viewModel.tags.collectAsState()
    val aliases by viewModel.aliases.collectAsState()
    val refs by viewModel.refs.collectAsState()
    val todoStates by viewModel.todoStates.collectAsState()
    val captureState by viewModel.editorState.collectAsState()
    val errorMessage by viewModel.errorMessage.collectAsState()
    val createId by viewModel.createId.collectAsState()
    val geoCoords by viewModel.geoCoords.collectAsState()
    val attachments by viewModel.attachments.collectAsState()
    val scheduled by viewModel.scheduled.collectAsState()
    val deadline by viewModel.deadline.collectAsState()
    val flashcardType by viewModel.flashcardType.collectAsState()
    val clozeType by viewModel.clozeType.collectAsState()
    val publishEnabled by viewModel.publishEnabled.collectAsState()
    val arcologyKey by viewModel.arcologyKey.collectAsState()
    val arcologyExpire by viewModel.arcologyExpire.collectAsState()
    val arcologyAllowCrawl by viewModel.arcologyAllowCrawl.collectAsState()
    val arcologyPageTemplate by viewModel.arcologyPageTemplate.collectAsState()

    // Which field-editor sheet is open (null = none)
    var openSheet by remember { mutableStateOf<FieldSheet?>(null) }

    // Clear handler for inspector rows
    val onClearField: (FieldSheet) -> Unit = { field ->
        when (field) {
            FieldSheet.NodeId -> viewModel.updateCreateId(false)
            FieldSheet.Tags -> viewModel.updateTags(emptyList())
            FieldSheet.Aliases -> viewModel.updateAliases(emptyList())
            FieldSheet.Refs -> viewModel.updateRefs(emptyList())
            FieldSheet.Scheduled -> viewModel.updatePlanning(PlanningKind.SCHEDULED, null)
            FieldSheet.Deadline -> viewModel.updatePlanning(PlanningKind.DEADLINE, null)
            FieldSheet.Location -> viewModel.updateGeoCoords(null)
            FieldSheet.Attachments -> attachments.forEach { viewModel.removeAttachment(it.uri) }
            FieldSheet.Flashcard -> viewModel.updateFlashcardType(null)
            FieldSheet.Publish -> {
                viewModel.updateArcologyKey("")
                viewModel.updatePublishEnabled(false)
            }
        }
    }

    // NodeId toggles on directly (no sheet); others open a sheet
    val onOpenSheet: (FieldSheet) -> Unit = { sheet ->
        if (sheet == FieldSheet.NodeId) {
            viewModel.updateCreateId(true)
        } else {
            openSheet = sheet
        }
    }

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .imePadding(),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        // TODO state selector — single dropdown row (replaces N-chip FlowRow)
        TodoDropdownRow(
            todoState = todoState,
            todoStates = todoStates,
            onSelect = { viewModel.updateTodoState(it) }
        )

        // Single text field for entire node content (heading + properties + body)
        OrgBodyEditor(
            value = fullContentFieldValue,
            onValueChange = { newValue ->
                fullContentFieldValueChanged(newValue)
                viewModel.updateFullContent(newValue)
            },
            modifier = Modifier.fillMaxWidth(),
            searchService = searchService,
            placeholder = "* Title :tags:\n:PROPERTIES:\n:ID:       abc123\n:END:\nBody content...",
            scrollState = outerScrollState
        )

        // Properties inspector list (replaces the 10-chip FlowRow + inline sections)
        PropertiesInspectorList(
            tags = tags,
            aliases = aliases,
            refs = refs,
            createId = createId,
            scheduled = scheduled,
            deadline = deadline,
            geoCoords = geoCoords,
            attachments = attachments,
            flashcardType = flashcardType,
            clozeType = clozeType,
            arcologyKey = arcologyKey,
            arcologyExpire = arcologyExpire,
            arcologyAllowCrawl = arcologyAllowCrawl,
            arcologyPageTemplate = arcologyPageTemplate,
            onOpenSheet = onOpenSheet,
            onClearField = onClearField
        )

        // Save button with long-press menu
        var showSaveMenu by remember { mutableStateOf(false) }
        Box {
            Surface(
                onClick = {
                    showSaveMenu = false
                    viewModel.save(context, SaveAction.OpenReadOnly)
                },
                enabled = captureState == EditorState.Editing,
                modifier = Modifier.fillMaxWidth(),
                shape = MaterialTheme.shapes.small,
                color = MaterialTheme.colorScheme.primary,
                tonalElevation = 0.dp
            ) {
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .combinedClickable(
                            enabled = captureState == EditorState.Editing,
                            onClick = {
                                showSaveMenu = false
                                viewModel.save(context, SaveAction.OpenReadOnly)
                            },
                            onLongClick = { showSaveMenu = true }
                        )
                        .padding(16.dp),
                    contentAlignment = Alignment.Center
                ) {
                    when (captureState) {
                        EditorState.Loading -> Text("Loading...")
                        EditorState.Editing -> Text("Save")
                        EditorState.Saving -> {
                            CircularProgressIndicator(
                                modifier = Modifier.size(18.dp),
                                strokeWidth = 2.dp,
                                color = MaterialTheme.colorScheme.onPrimary
                            )
                            Spacer(Modifier.width(8.dp))
                            Text("Saving...")
                        }
                        EditorState.Saved -> Text("Saved!")
                        EditorState.Error -> Text("Retry")
                    }
                }
            }

            DropdownMenu(
                expanded = showSaveMenu,
                onDismissRequest = { showSaveMenu = false }
            ) {
                DropdownMenuItem(
                    text = { Text("Save & Open") },
                    onClick = {
                        showSaveMenu = false
                        viewModel.save(context, SaveAction.OpenReadOnly)
                    },
                    leadingIcon = {
                        Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(18.dp))
                    }
                )
                DropdownMenuItem(
                    text = { Text("Save & Continue Editing") },
                    onClick = {
                        showSaveMenu = false
                        viewModel.save(context, SaveAction.ContinueEditing)
                    },
                    leadingIcon = {
                        Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp))
                    }
                )
                DropdownMenuItem(
                    text = { Text("Save & Refile") },
                    onClick = {
                        showSaveMenu = false
                        viewModel.save(context, SaveAction.Refile)
                    },
                    leadingIcon = {
                        Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(18.dp))
                    }
                )
            }
        }

        // Error message
        if (captureState == EditorState.Error && errorMessage != null) {
            Text(
                text = errorMessage!!,
                color = MaterialTheme.colorScheme.error,
                style = MaterialTheme.typography.bodySmall
            )
        }
    }

    // Field editor sheets (one open at a time)
    when (openSheet) {
        FieldSheet.Tags -> TagsSheet(viewModel = viewModel, onDismiss = { openSheet = null })
        FieldSheet.Aliases -> AliasesSheet(viewModel = viewModel, onDismiss = { openSheet = null })
        FieldSheet.Refs -> RefsSheet(viewModel = viewModel, onDismiss = { openSheet = null })
        FieldSheet.Scheduled -> PlanningSheet(
            kind = PlanningKind.SCHEDULED,
            viewModel = viewModel,
            onDismiss = { openSheet = null }
        )
        FieldSheet.Deadline -> PlanningSheet(
            kind = PlanningKind.DEADLINE,
            viewModel = viewModel,
            onDismiss = { openSheet = null }
        )
        FieldSheet.Location -> LocationSheet(
            viewModel = viewModel,
            onDismiss = { openSheet = null },
            captureLocationEnabled = captureLocationEnabled,
            hasLocationPermission = hasLocationPermission,
            onCaptureLocationEnabledChange = { captureLocationEnabledChanged(it) },
            onShowLocationPicker = { showLocationPickerChanged(true) },
            locationPermissionLauncher = locationPermissionLauncher,
            locationService = locationService,
            scope = scope
        )
        FieldSheet.Attachments -> AttachmentsSheet(
            viewModel = viewModel,
            onDismiss = { openSheet = null },
            filePickerLauncher = filePickerLauncher,
            scope = scope
        )
        FieldSheet.Flashcard -> FlashcardSheet(viewModel = viewModel, onDismiss = { openSheet = null })
        FieldSheet.Publish -> PublishSheet(viewModel = viewModel, onDismiss = { openSheet = null })
        FieldSheet.NodeId, null -> { /* NodeId is a toggle, no sheet; null = nothing open */ }
    }
}

/**
 ,* Which field-editor sheet is open (null = none). NodeId is a boolean toggle
 ,* and never opens a sheet; it is included so the Add menu can route to it.
 ,*/
private enum class FieldSheet {
    NodeId, Tags, Aliases, Refs, Scheduled, Deadline, Location, Attachments, Flashcard, Publish
}

/**
 ,* Single TODO dropdown row replacing the N-chip FlowRow.
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TodoDropdownRow(
    todoState: String?,
    todoStates: List<String>,
    onSelect: (String?) -> Unit
) {
    var expanded by remember { mutableStateOf(false) }
    val options = remember(todoStates) { listOf(null) + todoStates }
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text("TODO:", style = MaterialTheme.typography.labelLarge)
        ExposedDropdownMenuBox(
            expanded = expanded,
            onExpandedChange = { expanded = it }
        ) {
            OutlinedTextField(
                value = todoState ?: "None",
                onValueChange = {},
                readOnly = true,
                singleLine = true,
                modifier = Modifier
                    .fillMaxWidth()
                    .menuAnchor(),
                trailingIcon = {
                    ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
                }
            )
            ExposedDropdownMenu(
                expanded = expanded,
                onDismissRequest = { expanded = false }
            ) {
                options.forEach { state ->
                    DropdownMenuItem(
                        text = { Text(state ?: "None") },
                        onClick = {
                            onSelect(state)
                            expanded = false
                        },
                        trailingIcon = if (state == todoState) {
                            {
                                Icon(
                                    imageVector = Icons.Default.Check,
                                    contentDescription = "Selected",
                                    modifier = Modifier.size(18.dp)
                                )
                            }
                        } else null
                    )
                }
            }
        }
    }
}

/**
 ,* Top app bar for Editing mode: holds the "Edit" title and Template action.
 ,* Consumes the status-bar inset so the content isn't pushed down by the
 ,* empty top-bar slot the Scaffold would otherwise reserve.
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditingTopBar(
    captureTemplates: List<computer.whatthefuck.arcology.app.data.CaptureTemplate>,
    selectedTemplateId: String?,
    onShowTemplateSheet: () -> Unit
) {
    TopAppBar(
        title = { Text("Edit") },
        actions = {
            OutlinedButton(onClick = onShowTemplateSheet) {
                Icon(
                    imageVector = Icons.Default.Article,
                    contentDescription = null,
                    modifier = Modifier.size(18.dp)
                )
                Spacer(Modifier.width(4.dp))
                Text(
                    captureTemplates.find { it.id == selectedTemplateId }?.name
                        ?: "Template"
                )
            }
        }
    )
}

/**
 ,* The Properties inspector: key/value rows for set fields + an Add-property menu.
 ,*/
@Composable
private fun PropertiesInspectorList(
    tags: List<String>,
    aliases: List<String>,
    refs: List<String>,
    createId: Boolean,
    scheduled: String?,
    deadline: String?,
    geoCoords: computer.whatthefuck.arcology.domain.GeoCoordinate?,
    attachments: List<PendingAttachment>,
    flashcardType: FlashcardType?,
    clozeType: ClozeType,
    arcologyKey: String,
    arcologyExpire: String?,
    arcologyAllowCrawl: Boolean?,
    arcologyPageTemplate: String?,
    onOpenSheet: (FieldSheet) -> Unit,
    onClearField: (FieldSheet) -> Unit
) {
    val anyRowSet = createId || tags.isNotEmpty() || aliases.isNotEmpty() ||
        refs.isNotEmpty() || scheduled != null || deadline != null || geoCoords != null ||
        attachments.isNotEmpty() || flashcardType != null || arcologyKey.isNotBlank()

    if (anyRowSet) {
        Text("Properties", style = MaterialTheme.typography.labelMedium)
    }

    Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
        if (createId) {
            PropertyRow(
                label = "ID",
                value = "Create ID ✓",
                onClick = { onOpenSheet(FieldSheet.NodeId) },
                onClear = { onClearField(FieldSheet.NodeId) }
            )
        }
        if (tags.isNotEmpty()) {
            PropertyRow(
                label = ":TAGS:",
                value = tags.joinToString(" "),
                onClick = { onOpenSheet(FieldSheet.Tags) },
                onClear = { onClearField(FieldSheet.Tags) }
            )
        }
        if (aliases.isNotEmpty()) {
            PropertyRow(
                label = ":ROAM_ALIASES:",
                value = aliases.joinToString(", "),
                onClick = { onOpenSheet(FieldSheet.Aliases) },
                onClear = { onClearField(FieldSheet.Aliases) }
            )
        }
        if (refs.isNotEmpty()) {
            PropertyRow(
                label = ":ROAM_REFS:",
                value = refs.joinToString(" "),
                onClick = { onOpenSheet(FieldSheet.Refs) },
                onClear = { onClearField(FieldSheet.Refs) }
            )
        }
        if (scheduled != null) {
            PropertyRow(
                label = "SCHEDULED:",
                value = scheduled,
                onClick = { onOpenSheet(FieldSheet.Scheduled) },
                onClear = { onClearField(FieldSheet.Scheduled) }
            )
        }
        if (deadline != null) {
            PropertyRow(
                label = "DEADLINE:",
                value = deadline,
                onClick = { onOpenSheet(FieldSheet.Deadline) },
                onClear = { onClearField(FieldSheet.Deadline) }
            )
        }
        if (geoCoords != null) {
            PropertyRow(
                label = ":GEO:",
                value = "${geoCoords!!.latitude}, ${geoCoords!!.longitude}",
                onClick = { onOpenSheet(FieldSheet.Location) },
                onClear = { onClearField(FieldSheet.Location) }
            )
        }
        if (attachments.isNotEmpty()) {
            PropertyRow(
                label = "Attachments",
                value = "${attachments.size} file(s)",
                onClick = { onOpenSheet(FieldSheet.Attachments) },
                onClear = { onClearField(FieldSheet.Attachments) }
            )
        }
        if (flashcardType != null) {
            val cardValue = if (flashcardType == FlashcardType.CLOZE) {
                "Card: Cloze (${clozeType.name.lowercase()})"
            } else {
                "Card: ${flashcardType.name.lowercase().replaceFirstChar { it.uppercase() }}"
            }
            PropertyRow(
                label = ":FC_TYPE:",
                value = cardValue,
                onClick = { onOpenSheet(FieldSheet.Flashcard) },
                onClear = { onClearField(FieldSheet.Flashcard) }
            )
        }
        if (arcologyKey.isNotBlank()) {
            PropertyRow(
                label = ":ARCOLOGY_KEY:",
                value = arcologyKey,
                onClick = { onOpenSheet(FieldSheet.Publish) },
                onClear = { onClearField(FieldSheet.Publish) }
            )
            if (arcologyExpire != null) {
                PropertyRow(
                    label = ":ARCOLOGY_EXPIRE:",
                    value = arcologyExpire,
                    onClick = { onOpenSheet(FieldSheet.Publish) },
                    onClear = { onClearField(FieldSheet.Publish) }
                )
            }
            if (arcologyAllowCrawl != null) {
                PropertyRow(
                    label = ":ARCOLOGY_ALLOW_CRAWL:",
                    value = if (arcologyAllowCrawl) "t" else "nil",
                    onClick = { onOpenSheet(FieldSheet.Publish) },
                    onClear = { onClearField(FieldSheet.Publish) }
                )
            }
            if (arcologyPageTemplate != null) {
                PropertyRow(
                    label = ":ARCOLOGY_PAGE_TEMPLATE:",
                    value = arcologyPageTemplate,
                    onClick = { onOpenSheet(FieldSheet.Publish) },
                    onClear = { onClearField(FieldSheet.Publish) }
                )
            }
        }
    }

    AddPropertyMenu(
        createId = createId,
        tagsEmpty = tags.isEmpty(),
        aliasesEmpty = aliases.isEmpty(),
        refsEmpty = refs.isEmpty(),
        scheduledNull = scheduled == null,
        deadlineNull = deadline == null,
        locationNull = geoCoords == null,
        attachmentsEmpty = attachments.isEmpty(),
        flashcardNull = flashcardType == null,
        publishUnset = arcologyKey.isBlank(),
        onOpenSheet = onOpenSheet
    )
}

/**
 ,* One key/value row of the inspector. Tap to edit, ✕ to clear.
 ,*/
@Composable
private fun PropertyRow(
    label: String,
    value: String,
    onClick: () -> Unit,
    onClear: () -> Unit
) {
    Surface(
        onClick = onClick,
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colorScheme.surfaceVariant,
        shape = MaterialTheme.shapes.small
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 12.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = label,
                    style = MaterialTheme.typography.labelSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
                Text(
                    text = value,
                    style = MaterialTheme.typography.bodyMedium,
                    maxLines = 1
                )
            }
            IconButton(onClick = onClear) {
                Icon(
                    imageVector = Icons.Default.Close,
                    contentDescription = "Clear $label"
                )
            }
        }
    }
}

/**
 ,* "+ Add property ▾" anchored dropdown: grouped list of unset fields.
 ,*/
@Composable
private fun AddPropertyMenu(
    createId: Boolean,
    tagsEmpty: Boolean,
    aliasesEmpty: Boolean,
    refsEmpty: Boolean,
    scheduledNull: Boolean,
    deadlineNull: Boolean,
    locationNull: Boolean,
    attachmentsEmpty: Boolean,
    flashcardNull: Boolean,
    publishUnset: Boolean,
    onOpenSheet: (FieldSheet) -> Unit
) {
    var expanded by remember { mutableStateOf(false) }

    val identity = buildList {
        if (!createId) add("Node ID" to FieldSheet.NodeId)
        if (aliasesEmpty) add("Aliases" to FieldSheet.Aliases)
        if (refsEmpty) add("Refs" to FieldSheet.Refs)
    }
    val content = buildList {
        if (tagsEmpty) add("Tags" to FieldSheet.Tags)
        if (attachmentsEmpty) add("Attachments" to FieldSheet.Attachments)
    }
    val timePlace = buildList {
        if (scheduledNull) add("Scheduled" to FieldSheet.Scheduled)
        if (deadlineNull) add("Deadline" to FieldSheet.Deadline)
        if (locationNull) add("Location" to FieldSheet.Location)
    }
    val learning = buildList {
        if (flashcardNull) add("Flashcard" to FieldSheet.Flashcard)
    }
    val publishing = buildList {
        if (publishUnset) add("Publish" to FieldSheet.Publish)
    }
    val groups = listOf(
        "Identity" to identity,
        "Content" to content,
        "Time & Place" to timePlace,
        "Learning" to learning,
        "Publishing" to publishing
    )

    Box {
        TextButton(
            onClick = { expanded = true },
            modifier = Modifier.fillMaxWidth()
        ) {
            Icon(
                imageVector = Icons.Default.Add,
                contentDescription = null,
                modifier = Modifier.size(18.dp)
            )
            Spacer(Modifier.width(4.dp))
            Text("Add property")
        }
        DropdownMenu(
            expanded = expanded,
            onDismissRequest = { expanded = false }
        ) {
            groups.forEachIndexed { index, (header, items) ->
                if (items.isEmpty()) return@forEachIndexed
                if (index != 0) HorizontalDivider()
                Text(
                    text = header,
                    style = MaterialTheme.typography.labelSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                    modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
                )
                items.forEach { (label, sheet) ->
                    DropdownMenuItem(
                        text = { Text(label) },
                        onClick = {
                            expanded = false
                            onOpenSheet(sheet)
                        }
                    )
                }
            }
        }
    }
}

/**
 ,* Tags editor sheet.
 ,*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun TagsSheet(viewModel: OrgDocumentEditorViewModel, onDismiss: () -> Unit) {
    val tags by viewModel.tags.collectAsState()
    var input by remember { mutableStateOf(tags.joinToString(" ")) }
    LaunchedEffect(tags) { input = tags.joinToString(" ") }

    ModalBottomSheet(onDismissRequest = onDismiss) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text("Tags", style = MaterialTheme.typography.titleMedium)
            OutlinedTextField(
                value = input,
                onValueChange = { value ->
                    input = value
                    viewModel.updateTags(value.split(" ", ":").filter { it.isNotBlank() })
                },
                label = { Text("Tags (space or colon separated)") },
                singleLine = true,
                modifier = Modifier.fillMaxWidth()
            )
            if (tags.isNotEmpty()) {
                FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                    tags.forEach { tag ->
                        AssistChip(onClick = {}, label = { Text(tag) })
                    }
                }
            }
            Spacer(Modifier.height(24.dp))
        }
    }
}

/**
 ,* Aliases editor sheet.
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AliasesSheet(viewModel: OrgDocumentEditorViewModel, onDismiss: () -> Unit) {
    val aliases by viewModel.aliases.collectAsState()
    var input by remember { mutableStateOf(aliases.joinToString(",")) }
    LaunchedEffect(aliases) { input = aliases.joinToString(",") }

    ModalBottomSheet(onDismissRequest = onDismiss) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text("Aliases", style = MaterialTheme.typography.titleMedium)
            OutlinedTextField(
                value = input,
                onValueChange = { value ->
                    input = value
                    viewModel.updateAliases(value.split(",").map { it.trim() }.filter { it.isNotBlank() })
                },
                label = { Text("ROAM_ALIASES (comma separated)") },
                singleLine = true,
                modifier = Modifier.fillMaxWidth()
            )
            Spacer(Modifier.height(24.dp))
        }
    }
}

/**
 ,* Refs editor sheet.
 ,*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun RefsSheet(viewModel: OrgDocumentEditorViewModel, onDismiss: () -> Unit) {
    val refs by viewModel.refs.collectAsState()
    var input by remember { mutableStateOf(refs.joinToString(" ")) }
    LaunchedEffect(refs) { input = refs.joinToString(" ") }

    ModalBottomSheet(onDismissRequest = onDismiss) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text("Refs", style = MaterialTheme.typography.titleMedium)
            OutlinedTextField(
                value = input,
                onValueChange = { value ->
                    input = value
                    viewModel.updateRefs(value.split(" ", "\n").map { it.trim() }.filter { it.isNotBlank() })
                },
                label = { Text("ROAM_REFS (URLs, space separated)") },
                singleLine = true,
                modifier = Modifier.fillMaxWidth()
            )
            if (refs.isNotEmpty()) {
                FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                    refs.forEach { ref ->
                        AssistChip(onClick = {}, label = { Text(ref) })
                    }
                }
            }
            Spacer(Modifier.height(24.dp))
        }
    }
}

/**
 ,* SCHEDULED / DEADLINE editor sheet hosting PlanningEditorSection verbatim.
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PlanningSheet(
    kind: PlanningKind,
    viewModel: OrgDocumentEditorViewModel,
    onDismiss: () -> Unit
) {
    val currentFlow = when (kind) {
        PlanningKind.SCHEDULED -> viewModel.scheduled
        PlanningKind.DEADLINE -> viewModel.deadline
        PlanningKind.CLOSED -> kotlinx.coroutines.flow.flowOf(null)
    }
    val current by currentFlow.collectAsState(initial = null)

    ModalBottomSheet(onDismissRequest = onDismiss) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text("${kind.keyword} timestamp", style = MaterialTheme.typography.titleMedium)
            PlanningEditorSection(
                kind = kind,
                currentTimestamp = current,
                onApply = { ts -> viewModel.updatePlanning(kind, ts) },
                onClear = {
                    viewModel.updatePlanning(kind, null)
                    onDismiss()
                }
            )
            Spacer(Modifier.height(24.dp))
        }
    }
}

/**
 ,* Location sheet: current coords, "use current location" toggle, "pick on map".
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LocationSheet(
    viewModel: OrgDocumentEditorViewModel,
    onDismiss: () -> Unit,
    captureLocationEnabled: Boolean,
    hasLocationPermission: Boolean,
    onCaptureLocationEnabledChange: (Boolean) -> Unit,
    onShowLocationPicker: () -> Unit,
    locationPermissionLauncher: androidx.activity.compose.ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>,
    locationService: LocationService,
    scope: kotlinx.coroutines.CoroutineScope
) {
    val geoCoords by viewModel.geoCoords.collectAsState()

    ModalBottomSheet(onDismissRequest = onDismiss) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp)
        ) {
            Text("Location", style = MaterialTheme.typography.titleMedium)
            if (geoCoords != null) {
                Text(
                    text = "${geoCoords!!.latitude}, ${geoCoords!!.longitude}",
                    style = MaterialTheme.typography.bodyMedium
                )
            } else {
                Text(
                    text = "No location set",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
            Row(
                modifier = Modifier.fillMaxWidth(),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Switch(
                    checked = captureLocationEnabled,
                    onCheckedChange = { enabled ->
                        onCaptureLocationEnabledChange(enabled)
                        if (enabled && !hasLocationPermission) {
                            scope.launch {
                                locationPermissionLauncher.launch(
                                    arrayOf(
                                        Manifest.permission.ACCESS_FINE_LOCATION,
                                        Manifest.permission.ACCESS_COARSE_LOCATION
                                    )
                                )
                            }
                        } else if (enabled && hasLocationPermission) {
                            scope.launch {
                                viewModel.updateGeoCoords(locationService.getCurrentLocation())
                                viewModel.updateCreateId(true)
                            }
                        } else if (!enabled) {
                            viewModel.updateGeoCoords(null)
                        }
                    }
                )
                Spacer(Modifier.width(8.dp))
                Text("Use current location")
            }
            Button(
                onClick = {
                    onDismiss()
                    onShowLocationPicker()
                },
                modifier = Modifier.fillMaxWidth()
            ) {
                Icon(
                    imageVector = Icons.Default.Map,
                    contentDescription = null,
                    modifier = Modifier.size(18.dp)
                )
                Spacer(Modifier.width(8.dp))
                Text("Pick on map")
            }
            if (geoCoords != null) {
                OutlinedButton(
                    onClick = {
                        viewModel.updateGeoCoords(null)
                        onDismiss()
                    },
                    modifier = Modifier.fillMaxWidth()
                ) { Text("Clear location") }
            }
            Spacer(Modifier.height(24.dp))
        }
    }
}

/**
 ,* Attachments sheet: preview cards + Add Attachment (file picker).
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AttachmentsSheet(
    viewModel: OrgDocumentEditorViewModel,
    onDismiss: () -> Unit,
    filePickerLauncher: androidx.activity.compose.ManagedActivityResultLauncher<Array<String>, List<Uri>>,
    scope: kotlinx.coroutines.CoroutineScope
) {
    val attachments by viewModel.attachments.collectAsState()

    ModalBottomSheet(onDismissRequest = onDismiss) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text("Attachments", style = MaterialTheme.typography.titleMedium)
            attachments.forEach { attachment ->
                AttachmentPreviewCard(
                    attachment = attachment,
                    onRemove = { viewModel.removeAttachment(attachment.uri) }
                )
            }
            Button(
                onClick = {
                    scope.launch {
                        filePickerLauncher.launch(arrayOf("*/*"))
                    }
                },
                modifier = Modifier.fillMaxWidth()
            ) {
                Icon(
                    imageVector = Icons.Default.AttachFile,
                    contentDescription = null,
                    modifier = Modifier.size(18.dp)
                )
                Spacer(Modifier.width(8.dp))
                Text("Add Attachment")
            }
            Spacer(Modifier.height(24.dp))
        }
    }
}

/**
 ,* Flashcard sheet: explicit type list + cloze subtype dropdown (replaces the
 ,* tap-to-cycle FlashcardTypeChip).
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FlashcardSheet(viewModel: OrgDocumentEditorViewModel, onDismiss: () -> Unit) {
    val flashcardType by viewModel.flashcardType.collectAsState()
    val clozeType by viewModel.clozeType.collectAsState()
    var clozeExpanded by remember { mutableStateOf(false) }
    val types = remember {
        listOf(null to "None") + listOf(
            FlashcardType.NORMAL to "Normal",
            FlashcardType.DOUBLE to "Double",
            FlashcardType.CLOZE to "Cloze",
            FlashcardType.TEXT_INPUT to "Text input",
            FlashcardType.VOCAB to "Vocab"
        )
    }

    ModalBottomSheet(onDismissRequest = onDismiss) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text("Flashcard type", style = MaterialTheme.typography.titleMedium)
            types.forEach { (type, label) ->
                Surface(
                    onClick = {
                        viewModel.updateFlashcardType(type)
                    },
                    modifier = Modifier.fillMaxWidth(),
                    color = if (type == flashcardType)
                        MaterialTheme.colorScheme.primaryContainer
                    else MaterialTheme.colorScheme.surface,
                    shape = MaterialTheme.shapes.small
                ) {
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(12.dp),
                        horizontalArrangement = Arrangement.SpaceBetween,
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Text(label, style = MaterialTheme.typography.bodyMedium)
                        if (type == flashcardType) {
                            Icon(
                                imageVector = Icons.Default.Check,
                                contentDescription = "Selected",
                                modifier = Modifier.size(18.dp)
                            )
                        }
                    }
                }
            }
            if (flashcardType == FlashcardType.CLOZE) {
                Spacer(Modifier.height(8.dp))
                Text("Cloze subtype", style = MaterialTheme.typography.labelMedium)
                ExposedDropdownMenuBox(
                    expanded = clozeExpanded,
                    onExpandedChange = { clozeExpanded = it }
                ) {
                    OutlinedTextField(
                        value = clozeType.name.lowercase(),
                        onValueChange = {},
                        readOnly = true,
                        singleLine = true,
                        modifier = Modifier
                            .fillMaxWidth()
                            .menuAnchor(),
                        trailingIcon = {
                            ExposedDropdownMenuDefaults.TrailingIcon(expanded = clozeExpanded)
                        }
                    )
                    ExposedDropdownMenu(
                        expanded = clozeExpanded,
                        onDismissRequest = { clozeExpanded = false }
                    ) {
                        ClozeType.values().forEach { type ->
                            DropdownMenuItem(
                                text = { Text(type.name.lowercase()) },
                                onClick = {
                                    viewModel.updateClozeType(type)
                                    clozeExpanded = false
                                },
                                trailingIcon = if (type == clozeType) {
                                    {
                                        Icon(
                                            imageVector = Icons.Default.Check,
                                            contentDescription = "Selected",
                                            modifier = Modifier.size(18.dp)
                                        )
                                    }
                                } else null
                            )
                        }
                    }
                }
            }
            Spacer(Modifier.height(24.dp))
        }
    }
}

/**
 ,* Publish sheet hosting PublishSection (ARCOLOGY_KEY, expire, crawl, template).
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PublishSheet(viewModel: OrgDocumentEditorViewModel, onDismiss: () -> Unit) {
    val publishEnabled by viewModel.publishEnabled.collectAsState()
    val arcologyKey by viewModel.arcologyKey.collectAsState()
    val arcologyExpire by viewModel.arcologyExpire.collectAsState()
    val arcologyAllowCrawl by viewModel.arcologyAllowCrawl.collectAsState()
    val arcologyPageTemplate by viewModel.arcologyPageTemplate.collectAsState()

    ModalBottomSheet(onDismissRequest = onDismiss) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text("Publish to the Arcology", style = MaterialTheme.typography.titleMedium)
            PublishSection(
                publishEnabled = publishEnabled,
                arcologyKey = arcologyKey,
                arcologyExpire = arcologyExpire,
                arcologyAllowCrawl = arcologyAllowCrawl,
                arcologyPageTemplate = arcologyPageTemplate,
                onPublishEnabledChange = { viewModel.updatePublishEnabled(it) },
                onKeyChange = { viewModel.updateArcologyKey(it) },
                onExpireChange = { viewModel.updateArcologyExpire(it) },
                onAllowCrawlChange = { viewModel.updateArcologyAllowCrawl(it) },
                onPageTemplateChange = { viewModel.updateArcologyPageTemplate(it) }
            )
            Spacer(Modifier.height(24.dp))
        }
    }
}

/**
 *,* Displays a template selection bottom sheet.
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TemplateBottomSheet(
    templates: List<computer.whatthefuck.arcology.app.data.CaptureTemplate>,
    selectedId: String?,
    onSelect: (computer.whatthefuck.arcology.app.data.CaptureTemplate) -> Unit,
    onDismiss: () -> Unit
) {
    ModalBottomSheet(
        onDismissRequest = onDismiss
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
            Text(
                text = "Select Template",
                style = MaterialTheme.typography.titleLarge,
                modifier = Modifier.padding(bottom = 16.dp)
            )
            templates.forEach { template ->
                ListItem(
                    headlineContent = { Text(template.name) },
                    supportingContent = if (template.titlePattern.isNotBlank() || template.bodyPattern.isNotBlank()) {
                        {
                            Text(
                                text = buildString {
                                    if (template.titlePattern.isNotBlank()) {
                                        append("Title: ${template.titlePattern}")
                                    }
                                    if (template.bodyPattern.isNotBlank()) {
                                        if (isNotEmpty()) append(" | ")
                                        append("Body: ${template.bodyPattern.take(30)}...")
                                    }
                                },
                                maxLines = 1
                            )
                        }
                    } else null,
                    trailingContent = if (template.id == selectedId) {
                        {
                            Icon(
                                imageVector = Icons.Default.Check,
                                contentDescription = "Selected",
                                tint = MaterialTheme.colorScheme.primary
                            )
                        }
                    } else null,
                    modifier = Modifier.clickable { onSelect(template) }
                )
                HorizontalDivider()
            }
            Spacer(modifier = Modifier.height(32.dp))
        }
    }
}

/**
 *,* Dialog for prompting user for missing values.
 ,*/
@Composable
private fun PromptDialog(
    prompts: List<computer.whatthefuck.arcology.capture.PromptRequest>,
    onConfirm: (Map<String, String>) -> Unit,
    onDismiss: () -> Unit
) {
    var responses by remember { mutableStateOf(prompts.associate { it.label to "" }) }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("Fill in values") },
        text = {
            Column(
                verticalArrangement = Arrangement.spacedBy(12.dp)
            ) {
                prompts.forEach { prompt ->
                    OutlinedTextField(
                        value = responses[prompt.label] ?: "",
                        onValueChange = { newValue ->
                            responses = responses + (prompt.label to newValue)
                        },
                        label = { Text(prompt.label) },
                        singleLine = true,
                        modifier = Modifier.fillMaxWidth()
                    )
                }
            }
        },
        confirmButton = {
            TextButton(
                onClick = { onConfirm(responses) }
            ) {
                Text("Apply")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("Cancel")
            }
        }
    )
}

/**
 ,* Preview card for a pending attachment.
 ,*/
@Composable
private fun AttachmentPreviewCard(
    attachment: PendingAttachment,
    onRemove: () -> Unit
) {
    Card(
        modifier = Modifier.fillMaxWidth(),
        shape = RoundedCornerShape(8.dp),
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surfaceVariant
        )
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Row(
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Icon(
                    imageVector = when (attachment.type) {
                        AttachmentType.IMAGE -> Icons.Default.Image
                        AttachmentType.VIDEO -> Icons.Default.VideoFile
                        AttachmentType.FILE -> Icons.Default.AttachFile
                    },
                    contentDescription = null,
                    tint = MaterialTheme.colorScheme.onSurfaceVariant
                )

                Column {
                    Text(
                        text = attachment.filename,
                        style = MaterialTheme.typography.bodyMedium,
                        maxLines = 1
                    )
                    Text(
                        text = attachment.mimeType,
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        maxLines = 1
                    )
                }
            }

            IconButton(onClick = onRemove) {
                Icon(
                    imageVector = Icons.Default.Close,
                    contentDescription = "Remove attachment"
                )
            }
        }
    }
}

/**
 * Arcology publishing section: ARCOLOGY_KEY field gated on the publish toggle,
 * with optional ARCOLOGY_EXPIRE (inactive org timestamp via date picker),
 * ARCOLOGY_ALLOW_CRAWL checkbox, and ARCOLOGY_PAGE_TEMPLATE dropdown revealed
 * once the key is set.
 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PublishSection(
    publishEnabled: Boolean,
    arcologyKey: String,
    arcologyExpire: String?,
    arcologyAllowCrawl: Boolean?,
    arcologyPageTemplate: String?,
    onPublishEnabledChange: (Boolean) -> Unit,
    onKeyChange: (String) -> Unit,
    onExpireChange: (String?) -> Unit,
    onAllowCrawlChange: (Boolean?) -> Unit,
    onPageTemplateChange: (String?) -> Unit
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 4.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Text(
            "Publish to the Arcology",
            style = MaterialTheme.typography.labelMedium
        )

        Row(
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Switch(
                checked = publishEnabled,
                onCheckedChange = { onPublishEnabledChange(it) }
            )
            Text(
                if (publishEnabled) "Publishing" else "Draft (not published)",
                style = MaterialTheme.typography.bodyMedium
            )
        }

        if (publishEnabled) {
            OutlinedTextField(
                value = arcologyKey,
                onValueChange = onKeyChange,
                label = { Text("ARCOLOGY_KEY (site/path)") },
                placeholder = { Text("garden/my-page") },
                singleLine = true,
                modifier = Modifier.fillMaxWidth()
            )

            if (arcologyKey.isNotBlank()) {
                PublishExpireField(
                    currentTimestamp = arcologyExpire,
                    onApply = onExpireChange
                )

                Row(
                    modifier = Modifier.fillMaxWidth(),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Checkbox(
                        checked = arcologyAllowCrawl == true,
                        onCheckedChange = { checked ->
                            onAllowCrawlChange(if (checked) true else null)
                        }
                    )
                    Text("Allow crawling (ARCOLOGY_ALLOW_CRAWL)")
                }

                var templateExpanded by remember { mutableStateOf(false) }
                val templates = listOf("page", "wide", "topic", "app")
                ExposedDropdownMenuBox(
                    expanded = templateExpanded,
                    onExpandedChange = { templateExpanded = it }
                ) {
                    OutlinedTextField(
                        value = arcologyPageTemplate ?: "",
                        onValueChange = {},
                        readOnly = true,
                        label = { Text("Page template (ARCOLOGY_PAGE_TEMPLATE)") },
                        placeholder = { Text("page (default)") },
                        singleLine = true,
                        modifier = Modifier
                            .fillMaxWidth()
                            .menuAnchor(),
                        trailingIcon = {
                            ExposedDropdownMenuDefaults.TrailingIcon(expanded = templateExpanded)
                        }
                    )
                    ExposedDropdownMenu(
                        expanded = templateExpanded,
                        onDismissRequest = { templateExpanded = false }
                    ) {
                        templates.forEach { template ->
                            DropdownMenuItem(
                                text = { Text(template) },
                                onClick = {
                                    onPageTemplateChange(template)
                                    templateExpanded = false
                                },
                                trailingIcon = if (template == arcologyPageTemplate) {
                                    {
                                        Icon(
                                            imageVector = Icons.Default.Check,
                                            contentDescription = "Selected",
                                            modifier = Modifier.size(18.dp)
                                        )
                                    }
                                } else null
                            )
                        }
                    }
                }
            }
        }
    }
}

/**
 ,* ARCOLOGY_EXPIRE editor: date picker + optional time chip, emitting an
 ,* inactive org timestamp. Mirrors PlanningEditorSection's interaction.
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PublishExpireField(
    currentTimestamp: String?,
    onApply: (String?) -> Unit
) {
    val parsed = remember(currentTimestamp) { parseOrgTimestamp(currentTimestamp) }

    var selectedDate by remember(currentTimestamp) {
        mutableStateOf(parsed?.date ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date)
    }
    var timeEnabled by remember(currentTimestamp) { mutableStateOf(parsed?.time != null) }
    var timeText by remember(currentTimestamp) { mutableStateOf(parsed?.time?.let { "%02d:%02d".format(it.hour, it.minute) } ?: "23:59") }
    var showDatePicker by remember { mutableStateOf(false) }

    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
        Text(
            "Expires (ARCOLOGY_EXPIRE)",
            style = MaterialTheme.typography.labelMedium
        )

        Row(
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            OutlinedButton(onClick = { showDatePicker = true }) {
                Text(selectedDate.toString())
            }
            FilterChip(
                selected = timeEnabled,
                onClick = { timeEnabled = !timeEnabled },
                label = { Text("Time") }
            )
            if (timeEnabled) {
                OutlinedTextField(
                    value = timeText,
                    onValueChange = { timeText = it },
                    label = { Text("HH:mm") },
                    singleLine = true,
                    modifier = Modifier.width(100.dp)
                )
            }
        }

        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Button(onClick = {
                val time = if (timeEnabled) {
                    runCatching {
                        val (h, m) = timeText.split(":").map { it.toInt() }
                        LocalTime(h, m)
                    }.getOrNull()
                } else null
                val ts = PlanningInfoUtils.formatTimestamp(
                    date = selectedDate,
                    time = time,
                    isActive = false,
                    repeater = null,
                    showWeekday = true
                )
                onApply(ts)
            }) { Text("Apply") }
            if (currentTimestamp != null) {
                OutlinedButton(onClick = { onApply(null) }) { Text("Clear") }
            }
        }
    }

    if (showDatePicker) {
        val state = rememberDatePickerState(
            initialSelectedDateMillis = selectedDate.toEpochDays().toLong() * 86400000L
        )
        DatePickerDialog(
            onDismissRequest = { showDatePicker = false },
            confirmButton = {
                TextButton(onClick = {
                    state.selectedDateMillis?.let { millis ->
                        selectedDate = LocalDate.fromEpochDays((millis / 86400000L).toInt())
                    }
                    showDatePicker = false
                }) { Text("OK") }
            },
            dismissButton = {
                TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
            }
        ) {
            DatePicker(state = state)
        }
    }
}

/**
 * Planning timestamp editor section for SCHEDULED or DEADLINE.
 * Provides a date picker, optional time field, optional repeater field,
 * and Apply/Clear buttons. Builds the org timestamp string via
 * PlanningInfoUtils.formatTimestamp and calls onApply, or onClear to remove.
 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PlanningEditorSection(
    kind: PlanningKind,
    currentTimestamp: String?,
    onApply: (String) -> Unit,
    onClear: () -> Unit
) {
    // Parse current timestamp to seed the pickers
    val parsed = remember(currentTimestamp) { parseOrgTimestamp(currentTimestamp) }

    var selectedDate by remember(currentTimestamp) {
        mutableStateOf(parsed?.date ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date)
    }
    var timeEnabled by remember(currentTimestamp) { mutableStateOf(parsed?.time != null) }
    var timeText by remember(currentTimestamp) { mutableStateOf(parsed?.time?.let { "%02d:%02d".format(it.hour, it.minute) } ?: "09:00") }
    var repeaterText by remember(currentTimestamp) { mutableStateOf(parsed?.repeater ?: "") }
    var showDatePicker by remember { mutableStateOf(false) }

    Column(
        modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Text(
            "${kind.keyword} timestamp",
            style = MaterialTheme.typography.labelMedium
        )

        Row(
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            OutlinedButton(onClick = { showDatePicker = true }) {
                Text(selectedDate.toString())
            }
            FilterChip(
                selected = timeEnabled,
                onClick = { timeEnabled = !timeEnabled },
                label = { Text("Time") }
            )
            if (timeEnabled) {
                OutlinedTextField(
                    value = timeText,
                    onValueChange = { timeText = it },
                    label = { Text("HH:mm") },
                    singleLine = true,
                    modifier = Modifier.width(100.dp)
                )
            }
        }

        OutlinedTextField(
            value = repeaterText,
            onValueChange = { repeaterText = it.filter { c -> c.isLetterOrDigit() || c in "+.!?-" } },
            label = { Text("Repeater (e.g. +1d, ++1w, .+1m)") },
            singleLine = true,
            modifier = Modifier.fillMaxWidth()
        )

        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Button(onClick = {
                val time = if (timeEnabled) {
                    runCatching {
                        val (h, m) = timeText.split(":").map { it.toInt() }
                        LocalTime(h, m)
                    }.getOrNull()
                } else null
                val ts = PlanningInfoUtils.formatTimestamp(
                    date = selectedDate,
                    time = time,
                    isActive = true,
                    repeater = repeaterText.ifBlank { null },
                    showWeekday = true
                )
                onApply(ts)
            }) { Text("Apply") }
            if (currentTimestamp != null) {
                OutlinedButton(onClick = onClear) { Text("Clear") }
            }
        }

        if (currentTimestamp != null) {
            Text(
                text = "Current: $currentTimestamp",
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }

    if (showDatePicker) {
        val state = rememberDatePickerState(
            initialSelectedDateMillis = selectedDate.toEpochDays().toLong() * 86400000L
        )
        DatePickerDialog(
            onDismissRequest = { showDatePicker = false },
            confirmButton = {
                TextButton(onClick = {
                    state.selectedDateMillis?.let { millis ->
                        selectedDate = LocalDate.fromEpochDays((millis / 86400000L).toInt())
                    }
                    showDatePicker = false
                }) { Text("OK") }
            },
            dismissButton = {
                TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
            }
        ) {
            DatePicker(state = state)
        }
    }
}

private data class ParsedTimestamp(
    val date: LocalDate,
    val time: LocalTime?,
    val repeater: String?
)

private fun parseOrgTimestamp(raw: String?): ParsedTimestamp? {
    if (raw.isNullOrBlank()) return null
    val stripped = raw.trim().trim('<', '>', '[', ']')
    val parts = stripped.split(" ").filter { it.isNotBlank() }
    if (parts.isEmpty()) return null
    val date = runCatching { LocalDate.parse(parts[0]) }.getOrNull() ?: return null
    var idx = 1
    var time: LocalTime? = null
    var repeater: String? = null
    if (idx < parts.size && parts[idx].length == 3 && parts[idx][0].isLetter()) {
        // weekday abbrev — skip
        idx++
    }
    if (idx < parts.size && parts[idx].contains(":")) {
        time = runCatching {
            val (h, m) = parts[idx].split(":").map { it.toInt() }
            LocalTime(h, m)
        }.getOrNull()
        idx++
    }
    if (idx < parts.size) {
        repeater = parts[idx]
        idx++
    }
    return ParsedTimestamp(date, time, repeater)
}

Future Work

Keyboard Shortcuts

Support for bluetooth keyboards and devices like the Unihertz Titan 2:

  • Ctrl/Alt/Meta shortcuts for common actions (save, cancel, refile)

  • Navigation shortcuts (move between heading, expand/collapse)

  • Formatting shortcuts (bold, italic, verbatim)

Animations and Transitions

  • Smooth transitions between view/edit modes

  • Animated heading expansion/collapse

  • Navigation transitions (slide, fade)

Vim-style modal editing for power users:

  • Normal, Insert, and Command modes

  • hjkl navigation of headings and segments

  • Searchable command picker (M-x style)

  • Keyboard-driven refile and node manipulation

Advanced Input Methods

  • Speech-to-text input integration

  • Handwriting recognition for stylus input

Related Modules