OrgDocumentEditorScreen is the central screen of the Arcology mobile app. It serves two primary user flows:
Capture — creating a new note via inline routing in
MainActivity, starting in edit mode with template supportRead & 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 existingPlanningEditorSection,PublishSection,LocationPickerScreen, attachment picker, etc. verbatim); a trailing ✕ clears the field.An
+ Add property ▾row anchors a groupedDropdownMenu(Identity Content Time & Place Learning Publishing) listing only the unset fields. Choosing one opens that field's sheet.The TODO selector becomes a single
ExposedDropdownMenuBoxrow (TODO: TODO ▾) instead of an N-chipFlowRow.
This mirrors how org-mode itself displays configuration (the properties drawer + planning line), maps cleanly on to the keyboard-driven future ("Add property" becomes a searchable command picker), and keeps the ViewModel API untouched.
TemplateBottomSheet
A modal bottom sheet that displays the list of capture templates from appPreferences.captureTemplates. It shows template names and pattern snippets, highlights the selected template with a check mark, and triggers viewModel.selectTemplate() on tap.
This composable is triggered by the template button in EditingView header (showing the current template name or "Template" if none selected).
PromptDialog
An AlertDialog that prompts the user for values missing from template placeholders (%^{prompt}). It displays one OutlinedTextField per requested prompt and returns a map of label → value to the ViewModel via resolvePrompts().
This dialog appears after template selection when the template contains %^{...} escapes. Prompt labels are extracted by TemplateExpander.expand().
PropertiesInspectorList
The composable that renders the set of configured node fields as a key/value list, replacing the former chip row. For each set field it emits a PropertyRow (label + value + trailing clear ✕); rows are derived from the ViewModel's StateFlows (tags, aliases, refs, createId, scheduled, deadline, geoCoords, attachments, flashcardType, arcologyKey + its sub-properties).
A section header "Properties" is shown only when at least one row is present. Below the rows, an + Add property ▾ row anchors AddPropertyMenu, a DropdownMenu with dividers grouping the unset fields so the user can only add things they haven't already configured. Choosing an item invokes the onOpenSheet callback with the relevant FieldSheet variant.
AddPropertyMenu
A DropdownMenu anchored to the + Add property ▾ row. It lists the fields not currently set, grouped under headers with HorizontalDivider separators:
Identity — Node ID, Aliases, Refs
Content — Tags, Attachments
Time & Place — Scheduled, Deadline, Location
Learning — Flashcard
Publishing — Publish (
ARCOLOGY_KEY)
Each entry is a DropdownMenuItem whose onClick opens the matching field-editor sheet via onSelect(FieldSheet). Fields already set are omitted so the menu scales down as the node fills up.
PublishChipSection
The Arcology publishing UI: a "Publish" expandable chip (globe icon) with an expanded section for ARCOLOGY_KEY and, once the key is set, the optional publishing properties read by Arcology's Web Server:
ARCOLOGY_KEY— a plain text field for theSITE/pathroute key; setting it toggles the publish state onARCOLOGY_EXPIRE— a date picker plus optional time chip (same interaction pattern asPlanningEditorSection), emitting an inactive org timestampARCOLOGY_ALLOW_CRAWL— a checkbox row emittingt/nilARCOLOGY_PAGE_TEMPLATE— an exposed dropdown offering the templates the web server ships:page,wide,topic,app
Unlike the other metadata chips, the publish section is not hidden behind a confirm button — every edit splices straight into the properties drawer via the ViewModel, and on EditExisting saves the properties are persisted with editor.setProperty(). Toggling the chip off removes only :ARCOLOGY_KEY:; expire/crawl/template choices survive so re-publishing is fast.
AttachmentPreviewCard
A Card component that displays pending attachments queued for save. It shows the attachment type icon (=Image/=Video/=AttachFile=), filename, and MIME type. A remove button in the corner discards the attachment via viewModel.removeAttachment().
These preview cards appear in the attachments expanded section of EditingView before save. After save, the handleAttachments() method copies them to the data/<id>/<remainder>/<id> directory structure.
FieldSheet
An enum describing which field-editor sheet is currently open (or null when none). EditingView holds a single var sheet by remember { mutableStateOf<FieldSheet?>(null) } and renders the corresponding ModalBottomSheet wrapper. Each sheet hosts an existing editor verbatim:
TagsAliasesRefs—OutlinedTextField+AssistChiplist (lifted from the former inline sections).Scheduled/Deadline—PlanningEditorSection.Location—LocationPickerScreen.Attachments—AttachmentPreviewCardlist + "Add Attachment"Buttondriving the file picker launcher.Flashcard— an explicit type list (replacing the tap-to-cycleFlashcardTypeChip) plus the cloze-subtype dropdown.Publish—PublishSection(ARCOLOGY_KEY, expire, allow-crawl, page template).
Hosting every field editor in a ModalBottomSheet (rather than inline AnimatedVisibility sections) keeps the capture surface minimal and gives each field a focused, consistent editing context.
Source Code
The complete Kotlin source file containing all 8 composables above.
computer.whatthefuck.arcology.app.ui.screens.OrgDocumentEditorScreen
package computer.whatthefuck.arcology.app.ui.screens
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.rememberScrollState
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import computer.whatthefuck.arcology.app.data.AppPreferences
import computer.whatthefuck.arcology.app.data.LocationService
import computer.whatthefuck.arcology.app.ui.components.BacklinkItem
import computer.whatthefuck.arcology.app.ui.components.BacklinksPanel
import computer.whatthefuck.arcology.app.ui.components.ContentMatchItem
import computer.whatthefuck.arcology.app.ui.components.ImageRenderer
import computer.whatthefuck.arcology.app.ui.components.NodeBreadcrumb
import computer.whatthefuck.arcology.app.ui.components.OrgBodyEditor
import computer.whatthefuck.arcology.app.ui.theme.VulfMono
import computer.whatthefuck.arcology.app.ui.theme.VulfMonoLightItalic
import computer.whatthefuck.arcology.app.ui.components.renderer.OrgDocumentRenderer
import computer.whatthefuck.arcology.app.ui.components.renderer.RenderMode
import computer.whatthefuck.arcology.app.ui.components.renderer.ClozeState as RendererClozeState
import computer.whatthefuck.arcology.app.viewmodel.DocumentEditMode
import computer.whatthefuck.arcology.app.viewmodel.EditorDisplayMode
import computer.whatthefuck.arcology.app.viewmodel.EditorState
import computer.whatthefuck.arcology.app.viewmodel.OrgDocumentEditorViewModel
import computer.whatthefuck.arcology.app.viewmodel.PerNodeMetadata
import computer.whatthefuck.arcology.app.viewmodel.SaveAction
import computer.whatthefuck.arcology.domain.AttachmentType
import computer.whatthefuck.arcology.domain.ClozeType
import computer.whatthefuck.arcology.domain.FlashcardType
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.domain.PendingAttachment
import computer.whatthefuck.arcology.domain.createPendingAttachment
import computer.whatthefuck.arcology.editor.PlanningInfoUtils
import computer.whatthefuck.arcology.editor.PlanningKind
import computer.whatthefuck.arcology.flashcard.ClozeService
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import computer.whatthefuck.arcology.search.SearchService
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import xyz.lepisma.orgmode.OrgParseResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OrgDocumentEditorScreen(
viewModel: OrgDocumentEditorViewModel = koinViewModel(),
onSaved: () -> Unit = {},
onNavigateToNode: (nodeId: String) -> Unit = {},
onTagClick: (tag: String) -> Unit = {}
) {
val searchService: SearchService = koinInject()
val locationService: LocationService = koinInject()
val appPreferences: AppPreferences = koinInject()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val fullContent by viewModel.fullContent.collectAsState()
val tags by viewModel.tags.collectAsState()
val refs by viewModel.refs.collectAsState()
val childMetadata by viewModel.childMetadata.collectAsState()
val captureTemplates by viewModel.captureTemplates.collectAsState()
val selectedTemplateId by viewModel.selectedTemplateId.collectAsState()
val pendingPrompts by viewModel.pendingPrompts.collectAsState()
val geoCoords by viewModel.geoCoords.collectAsState()
// Display mode state
val displayMode by viewModel.displayMode.collectAsState()
val node by viewModel.node.collectAsState()
// Breadcrumbs state
val parentNodes by viewModel.parentNodes.collectAsState()
// Backlinks and content matches state
val backlinks by viewModel.backlinks.collectAsState()
val contentMatches by viewModel.contentMatches.collectAsState()
val backlinksExpanded by viewModel.backlinksExpanded.collectAsState()
val existingAttachments by viewModel.existingAttachments.collectAsState()
// Location capture state
var captureLocationEnabled by remember { mutableStateOf(appPreferences.isCaptureLocationEnabled()) }
var hasLocationPermission by remember {
mutableStateOf(locationService.hasLocationPermission())
}
// Permission launcher
val locationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
hasLocationPermission = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true
if (hasLocationPermission && captureLocationEnabled) {
scope.launch {
val location = locationService.getCurrentLocation()
viewModel.updateGeoCoords(location)
}
}
}
// Fetch location when capture location is enabled and we have permission
LaunchedEffect(captureLocationEnabled, hasLocationPermission) {
if (captureLocationEnabled && hasLocationPermission) {
val location = locationService.getCurrentLocation()
viewModel.updateGeoCoords(location)
} else if (!captureLocationEnabled) {
viewModel.updateGeoCoords(null)
}
}
var showTemplateSheet by remember { mutableStateOf(false) }
var showLocationPicker by remember { mutableStateOf(false) }
// FAB menu state
var fabExpanded by remember { mutableStateOf(false) }
var headingRefileSourceId by remember { mutableStateOf<String?>(null) }
var showLinkPicker by remember { mutableStateOf(false) }
var pendingLinkSelection by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var lastRefiledNodeId by remember { mutableStateOf<String?>(null) }
// Pending refile triggered by save-and-refile action
val pendingRefileNodeId by viewModel.pendingRefileNodeId.collectAsState()
LaunchedEffect(pendingRefileNodeId) {
if (pendingRefileNodeId != null) {
headingRefileSourceId = pendingRefileNodeId
viewModel.clearPendingRefile()
}
}
// Reload file content after refile or ID creation
fun reloadFileContent() {
val treeUri = appPreferences.getSelectedDirectoryUri()
if (treeUri != null) {
val fs = AndroidFileSystem(context, treeUri)
viewModel.loadFileContent(fs)
}
}
LaunchedEffect(lastRefiledNodeId) {
if (lastRefiledNodeId != null) {
reloadFileContent()
lastRefiledNodeId = null
}
}
// Track the full content as TextFieldValue for cursor position
var fullContentFieldValue by remember {
mutableStateOf(fullContent)
}
LaunchedEffect(fullContent) {
if (fullContent.text != fullContentFieldValue.text) {
fullContentFieldValue = fullContent
}
}
val snackbarHostState = remember { SnackbarHostState() }
// Show snackbar and reset after saved using SharedFlow to avoid StateFlow conflation
LaunchedEffect(Unit) {
viewModel.saveCompleted.collect {
onSaved()
viewModel.resetState()
snackbarHostState.showSnackbar(
message = "Saved",
duration = SnackbarDuration.Short
)
}
}
// File picker launcher for attachments
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uris: List<Uri> ->
scope.launch {
uris.forEach { uri ->
try {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (e: Exception) {
// Permission already granted or not needed
}
val attachment = context.createPendingAttachment(uri)
viewModel.addAttachment(attachment)
}
}
}
// Load file content when opening existing node
val editMode by viewModel.editMode.collectAsState()
LaunchedEffect(editMode, context) {
if (editMode is DocumentEditMode.EditExisting) {
val treeUri = appPreferences.getSelectedDirectoryUri()
if (treeUri != null) {
val fs = AndroidFileSystem(context, treeUri)
viewModel.loadFileContent(fs)
}
}
}
val outerScrollState = rememberScrollState()
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
when (displayMode) {
EditorDisplayMode.ReadOnly -> {
if (parentNodes.isNotEmpty()) {
NodeBreadcrumb(
parents = parentNodes,
currentTitle = node?.title ?: "Untitled",
onParentClick = onNavigateToNode
)
}
}
EditorDisplayMode.Editing -> {
EditingTopBar(
captureTemplates = captureTemplates,
selectedTemplateId = selectedTemplateId,
onShowTemplateSheet = { showTemplateSheet = true }
)
}
}
},
floatingActionButton = {
if (displayMode == EditorDisplayMode.ReadOnly) {
Box {
// Main FAB button - toggles menu
FloatingActionButton(
onClick = { fabExpanded = !fabExpanded },
modifier = Modifier.offset(y = 0.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "More options"
)
}
// Edit FAB - only shown when expanded
if (fabExpanded) {
FloatingActionButton(
onClick = {
fabExpanded = false
viewModel.enterEditingMode()
},
modifier = Modifier.offset(y = (-64).dp)
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Edit"
)
}
}
// Refile FAB - only shown when expanded
if (fabExpanded) {
FloatingActionButton(
onClick = {
fabExpanded = false
headingRefileSourceId = node?.id
},
modifier = Modifier.offset(y = (-128).dp)
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Refile"
)
}
}
}
}
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.imePadding()
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(outerScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Display mode switcher
when (displayMode) {
EditorDisplayMode.ReadOnly -> {
ReadOnlyView(
node = node,
fullContent = fullContent,
parseState = viewModel.parseState.collectAsState().value,
backlinks = backlinks,
contentMatches = contentMatches,
backlinksExpanded = backlinksExpanded,
onBacklinksExpandedChange = { viewModel.toggleBacklinksExpanded() },
onNavigateToNode = onNavigateToNode,
existingAttachments = existingAttachments,
fileSystem = viewModel.fileSystem,
onHeadingRefile = { headingId -> headingRefileSourceId = headingId },
onHeadingCreateId = { section ->
viewModel.createIdForHeading(section, context) {
reloadFileContent()
}
},
tags = tags,
refs = refs,
onTagClick = onTagClick,
flashcardType = viewModel.flashcardType.collectAsState().value,
flashcardPositions = viewModel.flashcardPositions.collectAsState().value,
reviewHistory = viewModel.reviewHistory.collectAsState().value,
childMetadata = childMetadata
)
}
EditorDisplayMode.Editing -> {
EditingView(
viewModel = viewModel,
searchService = searchService,
locationService = locationService,
appPreferences = appPreferences,
context = context,
scope = scope,
fullContentFieldValue = fullContentFieldValue,
captureLocationEnabled = captureLocationEnabled,
hasLocationPermission = hasLocationPermission,
showLocationPicker = showLocationPicker,
snackbarHostState = snackbarHostState,
onSaved = onSaved,
locationPermissionLauncher = locationPermissionLauncher,
filePickerLauncher = filePickerLauncher,
showLocationPickerChanged = { showLocationPicker = it },
captureLocationEnabledChanged = { captureLocationEnabled = it },
fullContentFieldValueChanged = { fullContentFieldValue = it },
outerScrollState = outerScrollState
)
}
}
// Template selection bottom sheet
if (showTemplateSheet) {
TemplateBottomSheet(
templates = captureTemplates,
selectedId = selectedTemplateId,
onSelect = { template ->
showTemplateSheet = false
viewModel.selectTemplate(template.id)
},
onDismiss = { showTemplateSheet = false }
)
}
// Prompt dialog for %^{...} escapes
if (pendingPrompts.isNotEmpty()) {
PromptDialog(
prompts = pendingPrompts,
onConfirm = { responses ->
viewModel.resolvePrompts(responses)
},
onDismiss = {
viewModel.clearTemplate()
}
)
}
// Location picker dialog
if (showLocationPicker) {
Dialog(
onDismissRequest = { showLocationPicker = false },
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
LocationPickerScreen(
initialLocation = geoCoords,
onLocationSelected = { selectedCoords ->
showLocationPicker = false
viewModel.updateGeoCoords(selectedCoords)
viewModel.updateCreateId(true)
},
onDismiss = { showLocationPicker = false }
)
}
}
// Refile picker dialog (unified: FAB or heading long-press)
if (headingRefileSourceId != null) {
val sourceId = headingRefileSourceId!!
Dialog(
onDismissRequest = { headingRefileSourceId = null },
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
NodePickerScreen(
searchService = searchService,
currentNodeId = node?.id ?: "",
title = "Refile to...",
onNodeSelected = { targetNode ->
headingRefileSourceId = null
viewModel.refileHeadingToNode(context, sourceId, targetNode) {
lastRefiledNodeId = sourceId
}
},
onDismiss = { headingRefileSourceId = null }
)
}
}
// Link picker dialog
if (showLinkPicker) {
Dialog(
onDismissRequest = {
showLinkPicker = false
pendingLinkSelection = null
},
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
NodePickerScreen(
searchService = searchService,
currentNodeId = node?.id ?: "",
initialQuery = pendingLinkSelection?.let { (start, end) -> fullContentFieldValue.text.substring(start, end) } ?: "",
title = "Link to...",
onNodeSelected = { targetNode ->
showLinkPicker = false
pendingLinkSelection?.let { (start, end) ->
val text = fullContentFieldValue.text
val selected = text.substring(start, end)
val linkText = "[[id:${targetNode.id}][$selected]]"
val newText = text.substring(0, start) + linkText + text.substring(end)
val newValue = TextFieldValue(
text = newText,
selection = TextRange(start + linkText.length)
)
fullContentFieldValue = newValue
viewModel.updateFullContent(newValue)
}
pendingLinkSelection = null
},
onDismiss = {
showLinkPicker = false
pendingLinkSelection = null
}
)
}
}
}
// Floating formatting toolbar when text is selected
val hasSelection = fullContentFieldValue.selection.start != fullContentFieldValue.selection.end
if (displayMode == EditorDisplayMode.Editing && hasSelection) {
val wrapSelection: (String, String) -> Unit = { prefix, suffix ->
val start = fullContentFieldValue.selection.min
val end = fullContentFieldValue.selection.max
if (start != end) {
val text = fullContentFieldValue.text
val selected = text.substring(start, end)
val wrapped = prefix + selected + suffix
val newText = text.substring(0, start) + wrapped + text.substring(end)
val newValue = TextFieldValue(
text = newText,
selection = androidx.compose.ui.text.TextRange(start + wrapped.length)
)
fullContentFieldValue = newValue
viewModel.updateFullContent(newValue)
}
}
Card(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
.wrapContentWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = { wrapSelection("*", "*") }) {
Text("*", fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)
}
TextButton(onClick = { wrapSelection("/", "/") }) {
Text("/", fontStyle = androidx.compose.ui.text.font.FontStyle.Italic)
}
TextButton(onClick = { wrapSelection("=", "=") }) {
Text("=")
}
TextButton(onClick = { wrapSelection("~", "~") }) {
Text("~")
}
TextButton(onClick = { wrapSelection("#+BEGIN_QUOTE\n", "\n#+END_QUOTE") }) {
Text("Q")
}
TextButton(onClick = {
val start = fullContentFieldValue.selection.min
val end = fullContentFieldValue.selection.max
if (start != end) {
pendingLinkSelection = start to end
showLinkPicker = true
}
}) {
Text("[[L]]")
}
// Cloze button - only shown when in CLOZE mode
val flashcardType = viewModel.flashcardType.collectAsState().value
if (flashcardType == FlashcardType.CLOZE) {
TextButton(onClick = {
val start = fullContentFieldValue.selection.min
val end = fullContentFieldValue.selection.max
if (start != end) {
val text = fullContentFieldValue.text
val nextHoleId = ClozeService().findMaxHoleId(text) + 1
val wrapped = "{{" + text.substring(start, end) + "}@$nextHoleId}"
val newText = text.substring(0, start) + wrapped + text.substring(end)
val newValue = TextFieldValue(
text = newText,
selection = androidx.compose.ui.text.TextRange(start + wrapped.length)
)
fullContentFieldValue = newValue
viewModel.updateFullContent(newValue)
}
}) {
Text("{{C}}")
}
}
}
}
}
}
}
}
/**
*,* Read-only view for a node with content renderer.
,* Breadcrumbs and edit button have been moved outside this component.
,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReadOnlyView(
node: OrgNode?,
fullContent: TextFieldValue,
parseState: OrgDocumentEditorViewModel.ParseState,
backlinks: List<BacklinkItem>,
contentMatches: List<ContentMatchItem>,
backlinksExpanded: Boolean,
onBacklinksExpandedChange: (Boolean) -> Unit,
onNavigateToNode: (nodeId: String) -> Unit,
existingAttachments: List<computer.whatthefuck.arcology.domain.OrgAttachment>,
fileSystem: AndroidFileSystem?,
onHeadingRefile: (nodeId: String) -> Unit,
onHeadingCreateId: (xyz.lepisma.orgmode.OrgSection) -> Unit,
onTagClick: (tag: String) -> Unit = {},
tags: List<String> = emptyList(),
refs: List<String> = emptyList(),
flashcardType: computer.whatthefuck.arcology.domain.FlashcardType? = null,
flashcardPositions: List<computer.whatthefuck.arcology.domain.FlashcardPosition> = emptyList(),
reviewHistory: Map<String, List<computer.whatthefuck.arcology.domain.FlashcardReview>> = emptyMap(),
childMetadata: Map<String, PerNodeMetadata> = emptyMap()
) {
Column(
modifier = Modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Content renderer - using pre-parsed AST from ViewModel (off main thread + cache)
when (parseState) {
is OrgDocumentEditorViewModel.ParseState.Loading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.padding(24.dp))
}
}
is OrgDocumentEditorViewModel.ParseState.Ready -> {
when (val parseResult = parseState.result) {
is OrgParseResult.Success -> {
SelectionContainer {
OrgDocumentRenderer(
document = parseResult.document,
mode = RenderMode.FULL_DOCUMENT,
onLinkClick = onNavigateToNode,
modifier = Modifier.fillMaxWidth(),
onHeadingOpen = onNavigateToNode,
onHeadingRefile = onHeadingRefile,
onHeadingCreateId = onHeadingCreateId,
nodeId = node?.id,
fileSystem = fileSystem,
fileUri = node?.file,
orgRoamRoot = "",
tags = tags,
refs = refs,
onTagClick = onTagClick,
flashcardType = flashcardType,
flashcardPositions = flashcardPositions,
reviewHistory = reviewHistory,
childMetadata = childMetadata
)
}
}
is OrgParseResult.Failure -> {
android.util.Log.e("OrgDocumentEditorScreen",
"Parse failed: ${parseResult.error} at char position ${parseResult.position}")
Text(
text = fullContent.text,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth()
)
}
}
}
else -> {
// Idle - nothing to render yet (node not loaded)
}
}
// Attachments section
if (existingAttachments.isNotEmpty() && fileSystem != null) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Attachments (${existingAttachments.size})",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
existingAttachments.forEach { attachment ->
when (attachment.type) {
computer.whatthefuck.arcology.domain.AttachmentType.IMAGE -> {
ImageRenderer(
attachment = attachment,
fileSystem = fileSystem,
modifier = Modifier.fillMaxWidth()
)
}
else -> {
// For non-image attachments, show a placeholder
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(4.dp)
) {
Text(
text = "${attachment.type.name}: ${attachment.resolvedPath.substringAfterLast("/")}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp)
)
}
}
}
}
}
}
// Backlinks panel
BacklinksPanel(
backlinks = backlinks,
contentMatches = contentMatches,
expanded = backlinksExpanded,
onExpandedChange = onBacklinksExpandedChange,
onBacklinkClick = onNavigateToNode,
modifier = Modifier.fillMaxWidth()
)
}
}
/**
,* Editing view: text editor + TODO dropdown + Properties inspector + Save.
,* Replaces the former 10-chip FlowRow + inline expanded sections.
,*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun EditingView(
viewModel: OrgDocumentEditorViewModel,
searchService: SearchService,
locationService: LocationService,
appPreferences: AppPreferences,
context: android.content.Context,
scope: kotlinx.coroutines.CoroutineScope,
fullContentFieldValue: TextFieldValue,
captureLocationEnabled: Boolean,
hasLocationPermission: Boolean,
showLocationPicker: Boolean,
snackbarHostState: SnackbarHostState,
onSaved: () -> Unit,
locationPermissionLauncher: androidx.activity.compose.ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>,
filePickerLauncher: androidx.activity.compose.ManagedActivityResultLauncher<Array<String>, List<Uri>>,
showLocationPickerChanged: (Boolean) -> Unit,
captureLocationEnabledChanged: (Boolean) -> Unit,
fullContentFieldValueChanged: (TextFieldValue) -> Unit,
outerScrollState: androidx.compose.foundation.ScrollState
) {
val todoState by viewModel.todoState.collectAsState()
val tags by viewModel.tags.collectAsState()
val aliases by viewModel.aliases.collectAsState()
val refs by viewModel.refs.collectAsState()
val todoStates by viewModel.todoStates.collectAsState()
val captureState by viewModel.editorState.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
val createId by viewModel.createId.collectAsState()
val geoCoords by viewModel.geoCoords.collectAsState()
val attachments by viewModel.attachments.collectAsState()
val scheduled by viewModel.scheduled.collectAsState()
val deadline by viewModel.deadline.collectAsState()
val flashcardType by viewModel.flashcardType.collectAsState()
val clozeType by viewModel.clozeType.collectAsState()
val publishEnabled by viewModel.publishEnabled.collectAsState()
val arcologyKey by viewModel.arcologyKey.collectAsState()
val arcologyExpire by viewModel.arcologyExpire.collectAsState()
val arcologyAllowCrawl by viewModel.arcologyAllowCrawl.collectAsState()
val arcologyPageTemplate by viewModel.arcologyPageTemplate.collectAsState()
// Which field-editor sheet is open (null = none)
var openSheet by remember { mutableStateOf<FieldSheet?>(null) }
// Clear handler for inspector rows
val onClearField: (FieldSheet) -> Unit = { field ->
when (field) {
FieldSheet.NodeId -> viewModel.updateCreateId(false)
FieldSheet.Tags -> viewModel.updateTags(emptyList())
FieldSheet.Aliases -> viewModel.updateAliases(emptyList())
FieldSheet.Refs -> viewModel.updateRefs(emptyList())
FieldSheet.Scheduled -> viewModel.updatePlanning(PlanningKind.SCHEDULED, null)
FieldSheet.Deadline -> viewModel.updatePlanning(PlanningKind.DEADLINE, null)
FieldSheet.Location -> viewModel.updateGeoCoords(null)
FieldSheet.Attachments -> attachments.forEach { viewModel.removeAttachment(it.uri) }
FieldSheet.Flashcard -> viewModel.updateFlashcardType(null)
FieldSheet.Publish -> {
viewModel.updateArcologyKey("")
viewModel.updatePublishEnabled(false)
}
}
}
// NodeId toggles on directly (no sheet); others open a sheet
val onOpenSheet: (FieldSheet) -> Unit = { sheet ->
if (sheet == FieldSheet.NodeId) {
viewModel.updateCreateId(true)
} else {
openSheet = sheet
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.imePadding(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// TODO state selector — single dropdown row (replaces N-chip FlowRow)
TodoDropdownRow(
todoState = todoState,
todoStates = todoStates,
onSelect = { viewModel.updateTodoState(it) }
)
// Single text field for entire node content (heading + properties + body)
OrgBodyEditor(
value = fullContentFieldValue,
onValueChange = { newValue ->
fullContentFieldValueChanged(newValue)
viewModel.updateFullContent(newValue)
},
modifier = Modifier.fillMaxWidth(),
searchService = searchService,
placeholder = "* Title :tags:\n:PROPERTIES:\n:ID: abc123\n:END:\nBody content...",
scrollState = outerScrollState
)
// Properties inspector list (replaces the 10-chip FlowRow + inline sections)
PropertiesInspectorList(
tags = tags,
aliases = aliases,
refs = refs,
createId = createId,
scheduled = scheduled,
deadline = deadline,
geoCoords = geoCoords,
attachments = attachments,
flashcardType = flashcardType,
clozeType = clozeType,
arcologyKey = arcologyKey,
arcologyExpire = arcologyExpire,
arcologyAllowCrawl = arcologyAllowCrawl,
arcologyPageTemplate = arcologyPageTemplate,
onOpenSheet = onOpenSheet,
onClearField = onClearField
)
// Save button with long-press menu
var showSaveMenu by remember { mutableStateOf(false) }
Box {
Surface(
onClick = {
showSaveMenu = false
viewModel.save(context, SaveAction.OpenReadOnly)
},
enabled = captureState == EditorState.Editing,
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.primary,
tonalElevation = 0.dp
) {
Box(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
enabled = captureState == EditorState.Editing,
onClick = {
showSaveMenu = false
viewModel.save(context, SaveAction.OpenReadOnly)
},
onLongClick = { showSaveMenu = true }
)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
when (captureState) {
EditorState.Loading -> Text("Loading...")
EditorState.Editing -> Text("Save")
EditorState.Saving -> {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
Text("Saving...")
}
EditorState.Saved -> Text("Saved!")
EditorState.Error -> Text("Retry")
}
}
}
DropdownMenu(
expanded = showSaveMenu,
onDismissRequest = { showSaveMenu = false }
) {
DropdownMenuItem(
text = { Text("Save & Open") },
onClick = {
showSaveMenu = false
viewModel.save(context, SaveAction.OpenReadOnly)
},
leadingIcon = {
Icon(Icons.Default.Visibility, contentDescription = null, modifier = Modifier.size(18.dp))
}
)
DropdownMenuItem(
text = { Text("Save & Continue Editing") },
onClick = {
showSaveMenu = false
viewModel.save(context, SaveAction.ContinueEditing)
},
leadingIcon = {
Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp))
}
)
DropdownMenuItem(
text = { Text("Save & Refile") },
onClick = {
showSaveMenu = false
viewModel.save(context, SaveAction.Refile)
},
leadingIcon = {
Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(18.dp))
}
)
}
}
// Error message
if (captureState == EditorState.Error && errorMessage != null) {
Text(
text = errorMessage!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
}
// Field editor sheets (one open at a time)
when (openSheet) {
FieldSheet.Tags -> TagsSheet(viewModel = viewModel, onDismiss = { openSheet = null })
FieldSheet.Aliases -> AliasesSheet(viewModel = viewModel, onDismiss = { openSheet = null })
FieldSheet.Refs -> RefsSheet(viewModel = viewModel, onDismiss = { openSheet = null })
FieldSheet.Scheduled -> PlanningSheet(
kind = PlanningKind.SCHEDULED,
viewModel = viewModel,
onDismiss = { openSheet = null }
)
FieldSheet.Deadline -> PlanningSheet(
kind = PlanningKind.DEADLINE,
viewModel = viewModel,
onDismiss = { openSheet = null }
)
FieldSheet.Location -> LocationSheet(
viewModel = viewModel,
onDismiss = { openSheet = null },
captureLocationEnabled = captureLocationEnabled,
hasLocationPermission = hasLocationPermission,
onCaptureLocationEnabledChange = { captureLocationEnabledChanged(it) },
onShowLocationPicker = { showLocationPickerChanged(true) },
locationPermissionLauncher = locationPermissionLauncher,
locationService = locationService,
scope = scope
)
FieldSheet.Attachments -> AttachmentsSheet(
viewModel = viewModel,
onDismiss = { openSheet = null },
filePickerLauncher = filePickerLauncher,
scope = scope
)
FieldSheet.Flashcard -> FlashcardSheet(viewModel = viewModel, onDismiss = { openSheet = null })
FieldSheet.Publish -> PublishSheet(viewModel = viewModel, onDismiss = { openSheet = null })
FieldSheet.NodeId, null -> { /* NodeId is a toggle, no sheet; null = nothing open */ }
}
}
/**
,* Which field-editor sheet is open (null = none). NodeId is a boolean toggle
,* and never opens a sheet; it is included so the Add menu can route to it.
,*/
private enum class FieldSheet {
NodeId, Tags, Aliases, Refs, Scheduled, Deadline, Location, Attachments, Flashcard, Publish
}
/**
,* Single TODO dropdown row replacing the N-chip FlowRow.
,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TodoDropdownRow(
todoState: String?,
todoStates: List<String>,
onSelect: (String?) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val options = remember(todoStates) { listOf(null) + todoStates }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("TODO:", style = MaterialTheme.typography.labelLarge)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = todoState ?: "None",
onValueChange = {},
readOnly = true,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
}
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { state ->
DropdownMenuItem(
text = { Text(state ?: "None") },
onClick = {
onSelect(state)
expanded = false
},
trailingIcon = if (state == todoState) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
modifier = Modifier.size(18.dp)
)
}
} else null
)
}
}
}
}
}
/**
,* Top app bar for Editing mode: holds the "Edit" title and Template action.
,* Consumes the status-bar inset so the content isn't pushed down by the
,* empty top-bar slot the Scaffold would otherwise reserve.
,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditingTopBar(
captureTemplates: List<computer.whatthefuck.arcology.app.data.CaptureTemplate>,
selectedTemplateId: String?,
onShowTemplateSheet: () -> Unit
) {
TopAppBar(
title = { Text("Edit") },
actions = {
OutlinedButton(onClick = onShowTemplateSheet) {
Icon(
imageVector = Icons.Default.Article,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text(
captureTemplates.find { it.id == selectedTemplateId }?.name
?: "Template"
)
}
}
)
}
/**
,* The Properties inspector: key/value rows for set fields + an Add-property menu.
,*/
@Composable
private fun PropertiesInspectorList(
tags: List<String>,
aliases: List<String>,
refs: List<String>,
createId: Boolean,
scheduled: String?,
deadline: String?,
geoCoords: computer.whatthefuck.arcology.domain.GeoCoordinate?,
attachments: List<PendingAttachment>,
flashcardType: FlashcardType?,
clozeType: ClozeType,
arcologyKey: String,
arcologyExpire: String?,
arcologyAllowCrawl: Boolean?,
arcologyPageTemplate: String?,
onOpenSheet: (FieldSheet) -> Unit,
onClearField: (FieldSheet) -> Unit
) {
val anyRowSet = createId || tags.isNotEmpty() || aliases.isNotEmpty() ||
refs.isNotEmpty() || scheduled != null || deadline != null || geoCoords != null ||
attachments.isNotEmpty() || flashcardType != null || arcologyKey.isNotBlank()
if (anyRowSet) {
Text("Properties", style = MaterialTheme.typography.labelMedium)
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (createId) {
PropertyRow(
label = "ID",
value = "Create ID ✓",
onClick = { onOpenSheet(FieldSheet.NodeId) },
onClear = { onClearField(FieldSheet.NodeId) }
)
}
if (tags.isNotEmpty()) {
PropertyRow(
label = ":TAGS:",
value = tags.joinToString(" "),
onClick = { onOpenSheet(FieldSheet.Tags) },
onClear = { onClearField(FieldSheet.Tags) }
)
}
if (aliases.isNotEmpty()) {
PropertyRow(
label = ":ROAM_ALIASES:",
value = aliases.joinToString(", "),
onClick = { onOpenSheet(FieldSheet.Aliases) },
onClear = { onClearField(FieldSheet.Aliases) }
)
}
if (refs.isNotEmpty()) {
PropertyRow(
label = ":ROAM_REFS:",
value = refs.joinToString(" "),
onClick = { onOpenSheet(FieldSheet.Refs) },
onClear = { onClearField(FieldSheet.Refs) }
)
}
if (scheduled != null) {
PropertyRow(
label = "SCHEDULED:",
value = scheduled,
onClick = { onOpenSheet(FieldSheet.Scheduled) },
onClear = { onClearField(FieldSheet.Scheduled) }
)
}
if (deadline != null) {
PropertyRow(
label = "DEADLINE:",
value = deadline,
onClick = { onOpenSheet(FieldSheet.Deadline) },
onClear = { onClearField(FieldSheet.Deadline) }
)
}
if (geoCoords != null) {
PropertyRow(
label = ":GEO:",
value = "${geoCoords!!.latitude}, ${geoCoords!!.longitude}",
onClick = { onOpenSheet(FieldSheet.Location) },
onClear = { onClearField(FieldSheet.Location) }
)
}
if (attachments.isNotEmpty()) {
PropertyRow(
label = "Attachments",
value = "${attachments.size} file(s)",
onClick = { onOpenSheet(FieldSheet.Attachments) },
onClear = { onClearField(FieldSheet.Attachments) }
)
}
if (flashcardType != null) {
val cardValue = if (flashcardType == FlashcardType.CLOZE) {
"Card: Cloze (${clozeType.name.lowercase()})"
} else {
"Card: ${flashcardType.name.lowercase().replaceFirstChar { it.uppercase() }}"
}
PropertyRow(
label = ":FC_TYPE:",
value = cardValue,
onClick = { onOpenSheet(FieldSheet.Flashcard) },
onClear = { onClearField(FieldSheet.Flashcard) }
)
}
if (arcologyKey.isNotBlank()) {
PropertyRow(
label = ":ARCOLOGY_KEY:",
value = arcologyKey,
onClick = { onOpenSheet(FieldSheet.Publish) },
onClear = { onClearField(FieldSheet.Publish) }
)
if (arcologyExpire != null) {
PropertyRow(
label = ":ARCOLOGY_EXPIRE:",
value = arcologyExpire,
onClick = { onOpenSheet(FieldSheet.Publish) },
onClear = { onClearField(FieldSheet.Publish) }
)
}
if (arcologyAllowCrawl != null) {
PropertyRow(
label = ":ARCOLOGY_ALLOW_CRAWL:",
value = if (arcologyAllowCrawl) "t" else "nil",
onClick = { onOpenSheet(FieldSheet.Publish) },
onClear = { onClearField(FieldSheet.Publish) }
)
}
if (arcologyPageTemplate != null) {
PropertyRow(
label = ":ARCOLOGY_PAGE_TEMPLATE:",
value = arcologyPageTemplate,
onClick = { onOpenSheet(FieldSheet.Publish) },
onClear = { onClearField(FieldSheet.Publish) }
)
}
}
}
AddPropertyMenu(
createId = createId,
tagsEmpty = tags.isEmpty(),
aliasesEmpty = aliases.isEmpty(),
refsEmpty = refs.isEmpty(),
scheduledNull = scheduled == null,
deadlineNull = deadline == null,
locationNull = geoCoords == null,
attachmentsEmpty = attachments.isEmpty(),
flashcardNull = flashcardType == null,
publishUnset = arcologyKey.isBlank(),
onOpenSheet = onOpenSheet
)
}
/**
,* One key/value row of the inspector. Tap to edit, ✕ to clear.
,*/
@Composable
private fun PropertyRow(
label: String,
value: String,
onClick: () -> Unit,
onClear: () -> Unit
) {
Surface(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1
)
}
IconButton(onClick = onClear) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear $label"
)
}
}
}
}
/**
,* "+ Add property ▾" anchored dropdown: grouped list of unset fields.
,*/
@Composable
private fun AddPropertyMenu(
createId: Boolean,
tagsEmpty: Boolean,
aliasesEmpty: Boolean,
refsEmpty: Boolean,
scheduledNull: Boolean,
deadlineNull: Boolean,
locationNull: Boolean,
attachmentsEmpty: Boolean,
flashcardNull: Boolean,
publishUnset: Boolean,
onOpenSheet: (FieldSheet) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val identity = buildList {
if (!createId) add("Node ID" to FieldSheet.NodeId)
if (aliasesEmpty) add("Aliases" to FieldSheet.Aliases)
if (refsEmpty) add("Refs" to FieldSheet.Refs)
}
val content = buildList {
if (tagsEmpty) add("Tags" to FieldSheet.Tags)
if (attachmentsEmpty) add("Attachments" to FieldSheet.Attachments)
}
val timePlace = buildList {
if (scheduledNull) add("Scheduled" to FieldSheet.Scheduled)
if (deadlineNull) add("Deadline" to FieldSheet.Deadline)
if (locationNull) add("Location" to FieldSheet.Location)
}
val learning = buildList {
if (flashcardNull) add("Flashcard" to FieldSheet.Flashcard)
}
val publishing = buildList {
if (publishUnset) add("Publish" to FieldSheet.Publish)
}
val groups = listOf(
"Identity" to identity,
"Content" to content,
"Time & Place" to timePlace,
"Learning" to learning,
"Publishing" to publishing
)
Box {
TextButton(
onClick = { expanded = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text("Add property")
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
groups.forEachIndexed { index, (header, items) ->
if (items.isEmpty()) return@forEachIndexed
if (index != 0) HorizontalDivider()
Text(
text = header,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
items.forEach { (label, sheet) ->
DropdownMenuItem(
text = { Text(label) },
onClick = {
expanded = false
onOpenSheet(sheet)
}
)
}
}
}
}
}
/**
,* Tags editor sheet.
,*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun TagsSheet(viewModel: OrgDocumentEditorViewModel, onDismiss: () -> Unit) {
val tags by viewModel.tags.collectAsState()
var input by remember { mutableStateOf(tags.joinToString(" ")) }
LaunchedEffect(tags) { input = tags.joinToString(" ") }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Tags", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = input,
onValueChange = { value ->
input = value
viewModel.updateTags(value.split(" ", ":").filter { it.isNotBlank() })
},
label = { Text("Tags (space or colon separated)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (tags.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
tags.forEach { tag ->
AssistChip(onClick = {}, label = { Text(tag) })
}
}
}
Spacer(Modifier.height(24.dp))
}
}
}
/**
,* Aliases editor sheet.
,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AliasesSheet(viewModel: OrgDocumentEditorViewModel, onDismiss: () -> Unit) {
val aliases by viewModel.aliases.collectAsState()
var input by remember { mutableStateOf(aliases.joinToString(",")) }
LaunchedEffect(aliases) { input = aliases.joinToString(",") }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Aliases", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = input,
onValueChange = { value ->
input = value
viewModel.updateAliases(value.split(",").map { it.trim() }.filter { it.isNotBlank() })
},
label = { Text("ROAM_ALIASES (comma separated)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(24.dp))
}
}
}
/**
,* Refs editor sheet.
,*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun RefsSheet(viewModel: OrgDocumentEditorViewModel, onDismiss: () -> Unit) {
val refs by viewModel.refs.collectAsState()
var input by remember { mutableStateOf(refs.joinToString(" ")) }
LaunchedEffect(refs) { input = refs.joinToString(" ") }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Refs", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = input,
onValueChange = { value ->
input = value
viewModel.updateRefs(value.split(" ", "\n").map { it.trim() }.filter { it.isNotBlank() })
},
label = { Text("ROAM_REFS (URLs, space separated)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (refs.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
refs.forEach { ref ->
AssistChip(onClick = {}, label = { Text(ref) })
}
}
}
Spacer(Modifier.height(24.dp))
}
}
}
/**
,* SCHEDULED / DEADLINE editor sheet hosting PlanningEditorSection verbatim.
,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PlanningSheet(
kind: PlanningKind,
viewModel: OrgDocumentEditorViewModel,
onDismiss: () -> Unit
) {
val currentFlow = when (kind) {
PlanningKind.SCHEDULED -> viewModel.scheduled
PlanningKind.DEADLINE -> viewModel.deadline
PlanningKind.CLOSED -> kotlinx.coroutines.flow.flowOf(null)
}
val current by currentFlow.collectAsState(initial = null)
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("${kind.keyword} timestamp", style = MaterialTheme.typography.titleMedium)
PlanningEditorSection(
kind = kind,
currentTimestamp = current,
onApply = { ts -> viewModel.updatePlanning(kind, ts) },
onClear = {
viewModel.updatePlanning(kind, null)
onDismiss()
}
)
Spacer(Modifier.height(24.dp))
}
}
}
/**
,* Location sheet: current coords, "use current location" toggle, "pick on map".
,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LocationSheet(
viewModel: OrgDocumentEditorViewModel,
onDismiss: () -> Unit,
captureLocationEnabled: Boolean,
hasLocationPermission: Boolean,
onCaptureLocationEnabledChange: (Boolean) -> Unit,
onShowLocationPicker: () -> Unit,
locationPermissionLauncher: androidx.activity.compose.ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>,
locationService: LocationService,
scope: kotlinx.coroutines.CoroutineScope
) {
val geoCoords by viewModel.geoCoords.collectAsState()
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Location", style = MaterialTheme.typography.titleMedium)
if (geoCoords != null) {
Text(
text = "${geoCoords!!.latitude}, ${geoCoords!!.longitude}",
style = MaterialTheme.typography.bodyMedium
)
} else {
Text(
text = "No location set",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Switch(
checked = captureLocationEnabled,
onCheckedChange = { enabled ->
onCaptureLocationEnabledChange(enabled)
if (enabled && !hasLocationPermission) {
scope.launch {
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
}
} else if (enabled && hasLocationPermission) {
scope.launch {
viewModel.updateGeoCoords(locationService.getCurrentLocation())
viewModel.updateCreateId(true)
}
} else if (!enabled) {
viewModel.updateGeoCoords(null)
}
}
)
Spacer(Modifier.width(8.dp))
Text("Use current location")
}
Button(
onClick = {
onDismiss()
onShowLocationPicker()
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Map,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Pick on map")
}
if (geoCoords != null) {
OutlinedButton(
onClick = {
viewModel.updateGeoCoords(null)
onDismiss()
},
modifier = Modifier.fillMaxWidth()
) { Text("Clear location") }
}
Spacer(Modifier.height(24.dp))
}
}
}
/**
,* Attachments sheet: preview cards + Add Attachment (file picker).
,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AttachmentsSheet(
viewModel: OrgDocumentEditorViewModel,
onDismiss: () -> Unit,
filePickerLauncher: androidx.activity.compose.ManagedActivityResultLauncher<Array<String>, List<Uri>>,
scope: kotlinx.coroutines.CoroutineScope
) {
val attachments by viewModel.attachments.collectAsState()
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Attachments", style = MaterialTheme.typography.titleMedium)
attachments.forEach { attachment ->
AttachmentPreviewCard(
attachment = attachment,
onRemove = { viewModel.removeAttachment(attachment.uri) }
)
}
Button(
onClick = {
scope.launch {
filePickerLauncher.launch(arrayOf("*/*"))
}
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.AttachFile,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Add Attachment")
}
Spacer(Modifier.height(24.dp))
}
}
}
/**
,* Flashcard sheet: explicit type list + cloze subtype dropdown (replaces the
,* tap-to-cycle FlashcardTypeChip).
,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FlashcardSheet(viewModel: OrgDocumentEditorViewModel, onDismiss: () -> Unit) {
val flashcardType by viewModel.flashcardType.collectAsState()
val clozeType by viewModel.clozeType.collectAsState()
var clozeExpanded by remember { mutableStateOf(false) }
val types = remember {
listOf(null to "None") + listOf(
FlashcardType.NORMAL to "Normal",
FlashcardType.DOUBLE to "Double",
FlashcardType.CLOZE to "Cloze",
FlashcardType.TEXT_INPUT to "Text input",
FlashcardType.VOCAB to "Vocab"
)
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Flashcard type", style = MaterialTheme.typography.titleMedium)
types.forEach { (type, label) ->
Surface(
onClick = {
viewModel.updateFlashcardType(type)
},
modifier = Modifier.fillMaxWidth(),
color = if (type == flashcardType)
MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
if (type == flashcardType) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
modifier = Modifier.size(18.dp)
)
}
}
}
}
if (flashcardType == FlashcardType.CLOZE) {
Spacer(Modifier.height(8.dp))
Text("Cloze subtype", style = MaterialTheme.typography.labelMedium)
ExposedDropdownMenuBox(
expanded = clozeExpanded,
onExpandedChange = { clozeExpanded = it }
) {
OutlinedTextField(
value = clozeType.name.lowercase(),
onValueChange = {},
readOnly = true,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = clozeExpanded)
}
)
ExposedDropdownMenu(
expanded = clozeExpanded,
onDismissRequest = { clozeExpanded = false }
) {
ClozeType.values().forEach { type ->
DropdownMenuItem(
text = { Text(type.name.lowercase()) },
onClick = {
viewModel.updateClozeType(type)
clozeExpanded = false
},
trailingIcon = if (type == clozeType) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
modifier = Modifier.size(18.dp)
)
}
} else null
)
}
}
}
}
Spacer(Modifier.height(24.dp))
}
}
}
/**
,* Publish sheet hosting PublishSection (ARCOLOGY_KEY, expire, crawl, template).
,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PublishSheet(viewModel: OrgDocumentEditorViewModel, onDismiss: () -> Unit) {
val publishEnabled by viewModel.publishEnabled.collectAsState()
val arcologyKey by viewModel.arcologyKey.collectAsState()
val arcologyExpire by viewModel.arcologyExpire.collectAsState()
val arcologyAllowCrawl by viewModel.arcologyAllowCrawl.collectAsState()
val arcologyPageTemplate by viewModel.arcologyPageTemplate.collectAsState()
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Publish to the Arcology", style = MaterialTheme.typography.titleMedium)
PublishSection(
publishEnabled = publishEnabled,
arcologyKey = arcologyKey,
arcologyExpire = arcologyExpire,
arcologyAllowCrawl = arcologyAllowCrawl,
arcologyPageTemplate = arcologyPageTemplate,
onPublishEnabledChange = { viewModel.updatePublishEnabled(it) },
onKeyChange = { viewModel.updateArcologyKey(it) },
onExpireChange = { viewModel.updateArcologyExpire(it) },
onAllowCrawlChange = { viewModel.updateArcologyAllowCrawl(it) },
onPageTemplateChange = { viewModel.updateArcologyPageTemplate(it) }
)
Spacer(Modifier.height(24.dp))
}
}
}
/**
*,* Displays a template selection bottom sheet.
,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TemplateBottomSheet(
templates: List<computer.whatthefuck.arcology.app.data.CaptureTemplate>,
selectedId: String?,
onSelect: (computer.whatthefuck.arcology.app.data.CaptureTemplate) -> Unit,
onDismiss: () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Select Template",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp)
)
templates.forEach { template ->
ListItem(
headlineContent = { Text(template.name) },
supportingContent = if (template.titlePattern.isNotBlank() || template.bodyPattern.isNotBlank()) {
{
Text(
text = buildString {
if (template.titlePattern.isNotBlank()) {
append("Title: ${template.titlePattern}")
}
if (template.bodyPattern.isNotBlank()) {
if (isNotEmpty()) append(" | ")
append("Body: ${template.bodyPattern.take(30)}...")
}
},
maxLines = 1
)
}
} else null,
trailingContent = if (template.id == selectedId) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
} else null,
modifier = Modifier.clickable { onSelect(template) }
)
HorizontalDivider()
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
/**
*,* Dialog for prompting user for missing values.
,*/
@Composable
private fun PromptDialog(
prompts: List<computer.whatthefuck.arcology.capture.PromptRequest>,
onConfirm: (Map<String, String>) -> Unit,
onDismiss: () -> Unit
) {
var responses by remember { mutableStateOf(prompts.associate { it.label to "" }) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Fill in values") },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
prompts.forEach { prompt ->
OutlinedTextField(
value = responses[prompt.label] ?: "",
onValueChange = { newValue ->
responses = responses + (prompt.label to newValue)
},
label = { Text(prompt.label) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(responses) }
) {
Text("Apply")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
/**
,* Preview card for a pending attachment.
,*/
@Composable
private fun AttachmentPreviewCard(
attachment: PendingAttachment,
onRemove: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = when (attachment.type) {
AttachmentType.IMAGE -> Icons.Default.Image
AttachmentType.VIDEO -> Icons.Default.VideoFile
AttachmentType.FILE -> Icons.Default.AttachFile
},
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Column {
Text(
text = attachment.filename,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1
)
Text(
text = attachment.mimeType,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Remove attachment"
)
}
}
}
}
/**
* Arcology publishing section: ARCOLOGY_KEY field gated on the publish toggle,
* with optional ARCOLOGY_EXPIRE (inactive org timestamp via date picker),
* ARCOLOGY_ALLOW_CRAWL checkbox, and ARCOLOGY_PAGE_TEMPLATE dropdown revealed
* once the key is set.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PublishSection(
publishEnabled: Boolean,
arcologyKey: String,
arcologyExpire: String?,
arcologyAllowCrawl: Boolean?,
arcologyPageTemplate: String?,
onPublishEnabledChange: (Boolean) -> Unit,
onKeyChange: (String) -> Unit,
onExpireChange: (String?) -> Unit,
onAllowCrawlChange: (Boolean?) -> Unit,
onPageTemplateChange: (String?) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Publish to the Arcology",
style = MaterialTheme.typography.labelMedium
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Switch(
checked = publishEnabled,
onCheckedChange = { onPublishEnabledChange(it) }
)
Text(
if (publishEnabled) "Publishing" else "Draft (not published)",
style = MaterialTheme.typography.bodyMedium
)
}
if (publishEnabled) {
OutlinedTextField(
value = arcologyKey,
onValueChange = onKeyChange,
label = { Text("ARCOLOGY_KEY (site/path)") },
placeholder = { Text("garden/my-page") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (arcologyKey.isNotBlank()) {
PublishExpireField(
currentTimestamp = arcologyExpire,
onApply = onExpireChange
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = arcologyAllowCrawl == true,
onCheckedChange = { checked ->
onAllowCrawlChange(if (checked) true else null)
}
)
Text("Allow crawling (ARCOLOGY_ALLOW_CRAWL)")
}
var templateExpanded by remember { mutableStateOf(false) }
val templates = listOf("page", "wide", "topic", "app")
ExposedDropdownMenuBox(
expanded = templateExpanded,
onExpandedChange = { templateExpanded = it }
) {
OutlinedTextField(
value = arcologyPageTemplate ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Page template (ARCOLOGY_PAGE_TEMPLATE)") },
placeholder = { Text("page (default)") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = templateExpanded)
}
)
ExposedDropdownMenu(
expanded = templateExpanded,
onDismissRequest = { templateExpanded = false }
) {
templates.forEach { template ->
DropdownMenuItem(
text = { Text(template) },
onClick = {
onPageTemplateChange(template)
templateExpanded = false
},
trailingIcon = if (template == arcologyPageTemplate) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
modifier = Modifier.size(18.dp)
)
}
} else null
)
}
}
}
}
}
}
}
/**
,* ARCOLOGY_EXPIRE editor: date picker + optional time chip, emitting an
,* inactive org timestamp. Mirrors PlanningEditorSection's interaction.
,*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PublishExpireField(
currentTimestamp: String?,
onApply: (String?) -> Unit
) {
val parsed = remember(currentTimestamp) { parseOrgTimestamp(currentTimestamp) }
var selectedDate by remember(currentTimestamp) {
mutableStateOf(parsed?.date ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date)
}
var timeEnabled by remember(currentTimestamp) { mutableStateOf(parsed?.time != null) }
var timeText by remember(currentTimestamp) { mutableStateOf(parsed?.time?.let { "%02d:%02d".format(it.hour, it.minute) } ?: "23:59") }
var showDatePicker by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Expires (ARCOLOGY_EXPIRE)",
style = MaterialTheme.typography.labelMedium
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(onClick = { showDatePicker = true }) {
Text(selectedDate.toString())
}
FilterChip(
selected = timeEnabled,
onClick = { timeEnabled = !timeEnabled },
label = { Text("Time") }
)
if (timeEnabled) {
OutlinedTextField(
value = timeText,
onValueChange = { timeText = it },
label = { Text("HH:mm") },
singleLine = true,
modifier = Modifier.width(100.dp)
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val time = if (timeEnabled) {
runCatching {
val (h, m) = timeText.split(":").map { it.toInt() }
LocalTime(h, m)
}.getOrNull()
} else null
val ts = PlanningInfoUtils.formatTimestamp(
date = selectedDate,
time = time,
isActive = false,
repeater = null,
showWeekday = true
)
onApply(ts)
}) { Text("Apply") }
if (currentTimestamp != null) {
OutlinedButton(onClick = { onApply(null) }) { Text("Clear") }
}
}
}
if (showDatePicker) {
val state = rememberDatePickerState(
initialSelectedDateMillis = selectedDate.toEpochDays().toLong() * 86400000L
)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let { millis ->
selectedDate = LocalDate.fromEpochDays((millis / 86400000L).toInt())
}
showDatePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
}
) {
DatePicker(state = state)
}
}
}
/**
* Planning timestamp editor section for SCHEDULED or DEADLINE.
* Provides a date picker, optional time field, optional repeater field,
* and Apply/Clear buttons. Builds the org timestamp string via
* PlanningInfoUtils.formatTimestamp and calls onApply, or onClear to remove.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PlanningEditorSection(
kind: PlanningKind,
currentTimestamp: String?,
onApply: (String) -> Unit,
onClear: () -> Unit
) {
// Parse current timestamp to seed the pickers
val parsed = remember(currentTimestamp) { parseOrgTimestamp(currentTimestamp) }
var selectedDate by remember(currentTimestamp) {
mutableStateOf(parsed?.date ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date)
}
var timeEnabled by remember(currentTimestamp) { mutableStateOf(parsed?.time != null) }
var timeText by remember(currentTimestamp) { mutableStateOf(parsed?.time?.let { "%02d:%02d".format(it.hour, it.minute) } ?: "09:00") }
var repeaterText by remember(currentTimestamp) { mutableStateOf(parsed?.repeater ?: "") }
var showDatePicker by remember { mutableStateOf(false) }
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"${kind.keyword} timestamp",
style = MaterialTheme.typography.labelMedium
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(onClick = { showDatePicker = true }) {
Text(selectedDate.toString())
}
FilterChip(
selected = timeEnabled,
onClick = { timeEnabled = !timeEnabled },
label = { Text("Time") }
)
if (timeEnabled) {
OutlinedTextField(
value = timeText,
onValueChange = { timeText = it },
label = { Text("HH:mm") },
singleLine = true,
modifier = Modifier.width(100.dp)
)
}
}
OutlinedTextField(
value = repeaterText,
onValueChange = { repeaterText = it.filter { c -> c.isLetterOrDigit() || c in "+.!?-" } },
label = { Text("Repeater (e.g. +1d, ++1w, .+1m)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val time = if (timeEnabled) {
runCatching {
val (h, m) = timeText.split(":").map { it.toInt() }
LocalTime(h, m)
}.getOrNull()
} else null
val ts = PlanningInfoUtils.formatTimestamp(
date = selectedDate,
time = time,
isActive = true,
repeater = repeaterText.ifBlank { null },
showWeekday = true
)
onApply(ts)
}) { Text("Apply") }
if (currentTimestamp != null) {
OutlinedButton(onClick = onClear) { Text("Clear") }
}
}
if (currentTimestamp != null) {
Text(
text = "Current: $currentTimestamp",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (showDatePicker) {
val state = rememberDatePickerState(
initialSelectedDateMillis = selectedDate.toEpochDays().toLong() * 86400000L
)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let { millis ->
selectedDate = LocalDate.fromEpochDays((millis / 86400000L).toInt())
}
showDatePicker = false
}) { Text("OK") }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) { Text("Cancel") }
}
) {
DatePicker(state = state)
}
}
}
private data class ParsedTimestamp(
val date: LocalDate,
val time: LocalTime?,
val repeater: String?
)
private fun parseOrgTimestamp(raw: String?): ParsedTimestamp? {
if (raw.isNullOrBlank()) return null
val stripped = raw.trim().trim('<', '>', '[', ']')
val parts = stripped.split(" ").filter { it.isNotBlank() }
if (parts.isEmpty()) return null
val date = runCatching { LocalDate.parse(parts[0]) }.getOrNull() ?: return null
var idx = 1
var time: LocalTime? = null
var repeater: String? = null
if (idx < parts.size && parts[idx].length == 3 && parts[idx][0].isLetter()) {
// weekday abbrev — skip
idx++
}
if (idx < parts.size && parts[idx].contains(":")) {
time = runCatching {
val (h, m) = parts[idx].split(":").map { it.toInt() }
LocalTime(h, m)
}.getOrNull()
idx++
}
if (idx < parts.size) {
repeater = parts[idx]
idx++
}
return ParsedTimestamp(date, time, repeater)
}Future Work
Keyboard Shortcuts
Support for bluetooth keyboards and devices like the Unihertz Titan 2:
Ctrl/Alt/Meta shortcuts for common actions (save, cancel, refile)
Navigation shortcuts (move between heading, expand/collapse)
Formatting shortcuts (bold, italic, verbatim)
Animations and Transitions
Smooth transitions between view/edit modes
Animated heading expansion/collapse
Navigation transitions (slide, fade)
Modal Keyboard Navigation
Vim-style modal editing for power users:
Normal, Insert, and Command modes
hjklnavigation of headings and segmentsSearchable command picker (
M-xstyle)Keyboard-driven refile and node manipulation
Advanced Input Methods
Speech-to-text input integration
Handwriting recognition for stylus input
Related Modules
Uses
OrgDocumentEditorViewModelfrom viewmodel.orgUses
OrgDocumentRendererfrom renderer.orgUses
OrgBodyEditorfrom body-editor.orgUses
NodePickerScreenfrom nodepicker.orgUses
LocationPickerScreenfrom locationpicker.orgUses
NodeBreadcrumb,BacklinksPanel,ImageRendererfrom components.org