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().

PropertiesSheet

The properties configurator is one scrollable ModalBottomSheet (opened from a compact "Properties" row beneath the editor). It stacks the field editors inline, grouped under section headers — Identity (create-ID toggle, Tags, Aliases, Refs), Time & Place (Scheduled, Deadline, Location), Content (Attachments), Learning (Flashcard), Publishing (Publish). Each section renders its field editor verbatim alongside a "Clear" button when the field is set; set-state comes from the ViewModel's StateFlows.

This replaces the former PropertiesInspectorList + AddPropertyMenu + per-field ModalBottomSheet trio (see FieldSheet in the history below).

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, map, app (map is the geotagged-POI map page, maps/web.org)

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.

EditingView text plumbing (Sora bridge)

The editing surface is OrgBodyEditorSora from sora-bridge.org. Text authority lives in the editor; typed changes debounce (250ms) to viewModel.updateFullContent. ViewModel-written splices (rebuildHeadingLine, rebuildPropertiesDrawer, updatePlanning, template applies, refile reloads) flow into the screen's mirrored fullContentFieldValue state and its fullContent flow; the screen bumps externalChangeKeyState for changes that didn't come from the editor's own export (tracked by lastExportedViaEditor), and the bridge pushes them into the editor.

Bypass paths that edit text outside the editor's Content — the link picker (from the selection panel's `L` action) and the timestamp picker — write ONLY to the ViewModel (updateFullContent) after clearing the marker (=externalChangeBumper(null)~ at screen level, lastExportedViaEditor null=). The screen's LaunchedEffect(fullContent) then sees a diff against both mirrors, bumps the key, and the bridge pushes the result into the editor with setText. Pre-assigning fullContentFieldValue in those handlers suppressed exactly that diff, which is why early versions silently dropped the insertion.

The timestamp picker opens when the editor's "Pick date…" completion item fires onTimestampPickerRequested; inserting a timestamp bumps timestampPickerConsumedKey so the bridge clears the language-side flag.

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.components.TimestampPickerPopup
import computer.whatthefuck.arcology.app.ui.components.sora.OrgBodyEditorSora
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.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)
    }
    // Selection reported by the sora editor (Editing mode) for the wrap toolbar
    var fullContentSelection by remember { mutableStateOf<TextRange?>(null) }
    // Bumped after the timestamp picker closes so the bridge clears the language flag
    var timestampPickerConsumedKey by remember { mutableStateOf<Any?>(null) }
    // Texts exported by the sora editor's own debounce (lastExportedViaEditor);
    // a _fullContent change NOT matching either is an external splice → bump key.
    var lastExportedViaEditor by remember { mutableStateOf<String?>(null) }
    var externalChangeKeyState by remember { mutableStateOf<Any?>(null) }
    LaunchedEffect(fullContent) {
        // Editor-originated exports mark lastExportedViaEditor before writing
        // _fullContent, so a diff here means: spliced by the ViewModel side
        // (metadata rebuild, refile, template). Only THAT bumps the key.
        if (fullContent.text != fullContentFieldValue.text &&
            fullContent.text != lastExportedViaEditor
        ) {
            fullContentFieldValue = fullContent
            externalChangeKeyState = Any()
        } else if (fullContent.text == lastExportedViaEditor) {
            // mirror the exported value without pushing back into the editor
            fullContentFieldValue = fullContent
        }
    }

    val snackbarHostState = remember { SnackbarHostState() }

    // Show snackbar after saved. The ViewModel resets its own state in
    // saveCapture/saveExistingNode with the right mode; a second arg-less
    // resetState() here falls through to the NewCapture reset and REPLACES
    // the edit buffer with a bare timestamp — the "fake node" after save bug.
    LaunchedEffect(Unit) {
        viewModel.saveCompleted.collect {
            onSaved()
            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)
            }
        }
    }

    // Outer scroll is used by ReadOnly mode only; Editing mode's editor
    // scrolls internally (sora virtualizes its own layout).
    val outerScrollState = rememberScrollState()
    val scrollMode = if (displayMode == EditorDisplayMode.Editing) {
        // Editing: no outer scroll — the editor pane fills and scrolls itself
        Modifier.fillMaxSize()
    } else {
        Modifier.verticalScroll(outerScrollState)
    }

    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()
                    .then(scrollMode)
                    .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 },
                            externalChangeBumper = { marker: String? -> lastExportedViaEditor = marker },
                            fullContentSelectionChanged = { fullContentSelection = it },
                            timestampPickerConsumedKeyFromParent = timestampPickerConsumedKey,
                            onTimestampPickerClosedInternal = { timestampPickerConsumedKey = Any() },
                            externalChangeFromRoot = externalChangeKeyState,
                            selectionFromRoot = fullContentSelection,
                            onRequestLinkPicker = {
                                fullContentSelection?.let { range ->
                                    if (range.start != range.end) {
                                        pendingLinkSelection = range.min to range.max
                                        showLinkPicker = true
                                    }
                                }
                            }
                        )
                    }
                }

                // 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)
                            )
                            lastExportedViaEditor = null
                            viewModel.updateFullContent(newValue)
                        }
                        pendingLinkSelection = null
                    },
                    onDismiss = {
                        showLinkPicker = false
                        pendingLinkSelection = null
                    }
                )
            }
        }
            }
        }
    }
}

/**
 *,* 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,
    externalChangeBumper: (String?) -> Unit,
    fullContentSelectionChanged: (TextRange?) -> Unit,
    timestampPickerConsumedKeyFromParent: Any?,
    onTimestampPickerClosedInternal: () -> Unit,
    externalChangeFromRoot: Any?,
    selectionFromRoot: TextRange?,
    onRequestLinkPicker: () -> Unit
) {
    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()

    // One properties configurator sheet replaces per-field sheets
    var showPropertiesSheet by remember { mutableStateOf(false) }
    var showTimestampPicker by remember { mutableStateOf(false) }

    // Mark a text as exported by the editor so the VM mirror at screen level
    // doesn't bump the external-change key back at us (no loop).
    val onValueChangeWithEchoTracking: (TextFieldValue) -> Unit = { newValue ->
        externalChangeBumper(newValue.text)
        fullContentFieldValueChanged(newValue)
        viewModel.updateFullContent(newValue)
    }

    // Splices coming from ViewModel setters (rebuildHeadingLine etc.) flow
    // through fullContentFieldValue; the screen's LaunchedEffect(fullContent)
    // bumps externalChangeKeyState for us.
    val propertiesSummary = buildList {
        if (createId) add("ID")
        if (tags.isNotEmpty()) add("Tags")
        if (aliases.isNotEmpty()) add("Aliases")
        if (refs.isNotEmpty()) add("Refs")
        if (scheduled != null) add("Scheduled")
        if (deadline != null) add("Deadline")
        if (geoCoords != null) add("Location")
        if (attachments.isNotEmpty()) add("Attachments")
        if (flashcardType != null) add("Card")
        if (arcologyKey.isNotBlank()) add("Publish")
    }.joinToString(" · ")

    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) }
        )

        // Sora-backed org editor: text authority lives in the editor; typed
        // changes debounce out to the ViewModel. Splices set by the ViewModel
        // (rebuildHeadingLine/rebuildPropertiesDrawer/updatePlanning etc.) push
        // back in through the externalChangeKey path.
        OrgBodyEditorSora(
            value = fullContentFieldValue,
            externalChangeKey = externalChangeFromRoot,
            onValueChange = onValueChangeWithEchoTracking,
            modifier = Modifier
                .fillMaxWidth()
                .weight(1f, fill = true)
                .heightIn(min = 320.dp),
            searchService = searchService,
            onSelectionChanged = { range -> fullContentSelectionChanged(range) },
            onTimestampPickerRequested = { requested ->
                if (requested) showTimestampPicker = true
            },
            timestampPickerConsumedKey = timestampPickerConsumedKeyFromParent,
            onLinkRequest = onRequestLinkPicker
        )

        // Properties inspector: one row + one scrollable sheet (no inline list)
        Surface(
            onClick = { showPropertiesSheet = true },
            modifier = Modifier.fillMaxWidth(),
            color = MaterialTheme.colorScheme.surfaceVariant,
            shape = MaterialTheme.shapes.small
        ) {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 12.dp, vertical = 10.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = "Properties",
                    style = MaterialTheme.typography.labelLarge,
                    modifier = Modifier.weight(1f)
                )
                Text(
                    text = propertiesSummary,
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }

        // 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
            )
        }
    }

    // The one properties sheet: inspector rows + all field editors stacked
    if (showPropertiesSheet) {
        PropertiesSheet(
            viewModel = viewModel,
            onDismiss = { showPropertiesSheet = false },
            captureLocationEnabled = captureLocationEnabled,
            hasLocationPermission = hasLocationPermission,
            onCaptureLocationEnabledChange = captureLocationEnabledChanged,
            onShowLocationPicker = { showPropertiesSheet = false; showLocationPickerChanged(true) },
            locationPermissionLauncher = locationPermissionLauncher,
            locationService = locationService,
            scope = scope,
            filePickerLauncher = filePickerLauncher
        )
    }

    // Timestamp picker (armed from the editor's "Pick date…" completion item)
    if (showTimestampPicker) {
        TimestampPickerPopup(
            onInsert = { date, time ->
                // Replace the armed lone '<' with the composed timestamp
                val selection = selectionFromRoot
                val text = fullContentFieldValue.text
                val cursor = selection?.min ?: 0
                val insertPos = (cursor - 1).coerceAtLeast(0)
                val tsString = PlanningInfoUtils.formatTimestamp(
                    date = date, time = time, isActive = true, repeater = null, showWeekday = true
                )
                val newText = text.substring(0, insertPos) + tsString + text.substring(insertPos + 1)
                val newValue = TextFieldValue(text = newText, selection = TextRange(insertPos + tsString.length))
                externalChangeBumper(null)
                viewModel.updateFullContent(newValue)
                showTimestampPicker = false
                onTimestampPickerClosedInternal()
            },
            onDismiss = {
                showTimestampPicker = false
                onTimestampPickerClosedInternal()
            }
        )
    }
}

/**
 ,* The single properties configurator: one scrollable ModalBottomSheet stacking
 ,* the inspector rows and every field editor section, grouped with section
 ,* headers. Replaces the per-field ModalBottomSheets.
 ,*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun PropertiesSheet(
    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,
    filePickerLauncher: androidx.activity.compose.ManagedActivityResultLauncher<Array<String>, List<Uri>>
) {
    val createId by viewModel.createId.collectAsState()
    val tags by viewModel.tags.collectAsState()
    val aliases by viewModel.aliases.collectAsState()
    val refs by viewModel.refs.collectAsState()
    val scheduled by viewModel.scheduled.collectAsState()
    val deadline by viewModel.deadline.collectAsState()
    val geoCoords by viewModel.geoCoords.collectAsState()
    val attachments by viewModel.attachments.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()

    // Sections the user manually revealed (collapsed unset sections hide behind
    // "Add X" rows, mirroring the old AddPropertyMenu behavior).
    var revealed by remember { mutableStateOf(setOf<String>()) }
    fun toggleReveal(name: String) {
        revealed = if (name in revealed) revealed - name else revealed + name
    }

    ModalBottomSheet(onDismissRequest = onDismiss) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .verticalScroll(rememberScrollState())
                .padding(horizontal = 16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            // Identity
            Text("Identity", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
            Surface(
                onClick = { viewModel.updateCreateId(!createId) },
                modifier = Modifier.fillMaxWidth(),
                color = MaterialTheme.colorScheme.surfaceVariant,
                shape = MaterialTheme.shapes.small
            ) {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 12.dp, vertical = 10.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Checkbox(checked = createId, onCheckedChange = { viewModel.updateCreateId(it) })
                    Text("Create node ID")
                }
            }
            SectionField(
                label = "Tags",
                isSet = tags.isNotEmpty(),
                expanded = tags.isNotEmpty() || "Tags" in revealed,
                onToggleExpanded = { toggleReveal("Tags") },
                onClear = { viewModel.updateTags(emptyList()) }
            ) {
                TagsField(viewModel)
            }
            SectionField(
                label = "Aliases",
                isSet = aliases.isNotEmpty(),
                expanded = aliases.isNotEmpty() || "Aliases" in revealed,
                onToggleExpanded = { toggleReveal("Aliases") },
                onClear = { viewModel.updateAliases(emptyList()) }
            ) {
                AliasesField(viewModel)
            }
            SectionField(
                label = "Refs",
                isSet = refs.isNotEmpty(),
                expanded = refs.isNotEmpty() || "Refs" in revealed,
                onToggleExpanded = { toggleReveal("Refs") },
                onClear = { viewModel.updateRefs(emptyList()) }
            ) {
                RefsField(viewModel)
            }

            // Time & Place
            Text("Time & Place", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
            SectionField(
                label = "Scheduled",
                isSet = scheduled != null,
                expanded = scheduled != null || "Scheduled" in revealed,
                onToggleExpanded = { toggleReveal("Scheduled") },
                onClear = { viewModel.updatePlanning(PlanningKind.SCHEDULED, null) }
            ) {
                PlanningEditorSection(
                    kind = PlanningKind.SCHEDULED,
                    currentTimestamp = scheduled,
                    onApply = { viewModel.updatePlanning(PlanningKind.SCHEDULED, it) },
                    onClear = { viewModel.updatePlanning(PlanningKind.SCHEDULED, null) }
                )
            }
            SectionField(
                label = "Deadline",
                isSet = deadline != null,
                expanded = deadline != null || "Deadline" in revealed,
                onToggleExpanded = { toggleReveal("Deadline") },
                onClear = { viewModel.updatePlanning(PlanningKind.DEADLINE, null) }
            ) {
                PlanningEditorSection(
                    kind = PlanningKind.DEADLINE,
                    currentTimestamp = deadline,
                    onApply = { viewModel.updatePlanning(PlanningKind.DEADLINE, it) },
                    onClear = { viewModel.updatePlanning(PlanningKind.DEADLINE, null) }
                )
            }
            SectionField(
                label = "Location",
                isSet = geoCoords != null,
                expanded = geoCoords != null || "Location" in revealed,
                onToggleExpanded = { toggleReveal("Location") },
                onClear = { viewModel.updateGeoCoords(null) }
            ) {
                LocationField(
                    viewModel = viewModel,
                    captureLocationEnabled = captureLocationEnabled,
                    hasLocationPermission = hasLocationPermission,
                    onCaptureLocationEnabledChange = onCaptureLocationEnabledChange,
                    onShowLocationPicker = onShowLocationPicker,
                    locationPermissionLauncher = locationPermissionLauncher,
                    locationService = locationService,
                    scope = scope
                )
            }

            // Content
            Text("Content", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
            SectionField(
                label = "Attachments",
                isSet = attachments.isNotEmpty(),
                expanded = attachments.isNotEmpty() || "Attachments" in revealed,
                onToggleExpanded = { toggleReveal("Attachments") },
                onClear = { attachments.forEach { viewModel.removeAttachment(it.uri) } }
            ) {
                AttachmentsField(viewModel, filePickerLauncher, scope)
            }

            // Learning
            Text("Learning", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
            SectionField(
                label = "Flashcard",
                isSet = flashcardType != null,
                expanded = flashcardType != null || "Flashcard" in revealed,
                onToggleExpanded = { toggleReveal("Flashcard") },
                onClear = { viewModel.updateFlashcardType(null) }
            ) {
                FlashcardField(viewModel)
            }

            // Publishing
            Text("Publishing", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
            SectionField(
                label = "Publish",
                isSet = arcologyKey.isNotBlank(),
                expanded = arcologyKey.isNotBlank() || "Publish" in revealed,
                onToggleExpanded = { toggleReveal("Publish") },
                onClear = {
                    viewModel.updateArcologyKey("")
                    viewModel.updatePublishEnabled(false)
                }
            ) {
                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))
        }
    }
}

/**
 ,* One collapsible field section: header with label + clear action, then the
 ,* field editor inline. Fields always show their editor; the clear affordance
 ,* appears only when set.
 ,*/
@Composable
private fun SectionField(
    label: String,
    isSet: Boolean,
    expanded: Boolean,
    onToggleExpanded: () -> Unit,
    onClear: () -> Unit,
    content: @Composable () -> Unit
) {
    if (expanded) {
        Column(modifier = Modifier.fillMaxWidth()) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = if (isSet) label else label,
                    style = MaterialTheme.typography.labelMedium,
                    color = MaterialTheme.colorScheme.primary
                )
                if (isSet) {
                    TextButton(onClick = onClear) {
                        Text("Clear", style = MaterialTheme.typography.labelSmall)
                    }
                }
            }
            content()
        }
    } else {
        Surface(
            onClick = onToggleExpanded,
            modifier = Modifier.fillMaxWidth(),
            color = MaterialTheme.colorScheme.surface,
            shape = MaterialTheme.shapes.small
        ) {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 12.dp, vertical = 10.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Icon(
                    imageVector = Icons.Default.Add,
                    contentDescription = null,
                    modifier = Modifier.size(18.dp)
                )
                Spacer(Modifier.width(8.dp))
                Text(
                    text = "Add $label",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}

/**
 ,* 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"
                )
            }
        }
    )
}




/**
 ,* Tags editor field (Properties sheet section content).
 ,*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun TagsField(viewModel: OrgDocumentEditorViewModel) {
    val tags by viewModel.tags.collectAsState()
    var input by remember { mutableStateOf(tags.joinToString(" ")) }
    LaunchedEffect(tags) { input = tags.joinToString(" ") }

    Column(
        modifier = Modifier.fillMaxWidth(),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        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) })
                }
            }
        }
    }
}

/**
 ,* Aliases editor field (Properties sheet section content).
 ,*/
@Composable
private fun AliasesField(viewModel: OrgDocumentEditorViewModel) {
    val aliases by viewModel.aliases.collectAsState()
    var input by remember { mutableStateOf(aliases.joinToString(",")) }
    LaunchedEffect(aliases) { input = aliases.joinToString(",") }

    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()
    )
}

/**
 ,* Refs editor field (Properties sheet section content).
 ,*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RefsField(viewModel: OrgDocumentEditorViewModel) {
    val refs by viewModel.refs.collectAsState()
    var input by remember { mutableStateOf(refs.joinToString(" ")) }
    LaunchedEffect(refs) { input = refs.joinToString(" ") }

    Column(
        modifier = Modifier.fillMaxWidth(),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        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) })
                }
            }
        }
    }
}


/**
 ,* Location field (Properties sheet section content).
 ,*/
@Composable
private fun LocationField(
    viewModel: OrgDocumentEditorViewModel,
    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()

    Column(
        modifier = Modifier.fillMaxWidth(),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        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 = 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")
        }
    }
}

/**
 ,* Attachments field (Properties sheet section content).
 ,*/
@Composable
private fun AttachmentsField(
    viewModel: OrgDocumentEditorViewModel,
    filePickerLauncher: androidx.activity.compose.ManagedActivityResultLauncher<Array<String>, List<Uri>>,
    scope: kotlinx.coroutines.CoroutineScope
) {
    val attachments by viewModel.attachments.collectAsState()

    Column(
        modifier = Modifier.fillMaxWidth(),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        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")
        }
    }
}

/**
 ,* Flashcard field (Properties sheet section content): explicit type list +
 ,* cloze subtype dropdown.
 ,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FlashcardField(viewModel: OrgDocumentEditorViewModel) {
    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"
        )
    }

    Column(
        modifier = Modifier.fillMaxWidth(),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        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
                        )
                    }
                }
            }
        }
    }
}


/**
 *,* 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", "map", "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