Arcology Engine

Editor UI Components

Contents

Four standalone composables used by ReadOnlyView and capture settings:

  • NodeBreadcrumb — horizontal scrollable breadcrumb bar showing the parent node hierarchy

  • BacklinksPanel — collapsible panel showing backlinks and FTS content matches to the current node

  • ImageRenderer — async image loader for =IMAGE=-type attachment rendering

  • CaptureSettingsScreen — settings screen for capture defaults (subdirectory, location toggle, template navigation)

None of these components manage their own state beyond local UI concerns (scroll, expand/collapse). All data is passed in from the ViewModel.

NodeBreadcrumb

Displays the parent node hierarchy as clickable chips separated by chevrons. Parent nodes with IDs are clickable (navigate up). Id-less headings (pure organizational headings with no :ID: property) are displayed but not interactive.

A CompactBreadcrumb variant exists for space-constrained UIs, showing just the outline path as a slash-separated trail.

Design: stripLinksToSourceOnly

The original breadcrumb displayed raw org links in titles, which made for ugly breadcrumbs. The stripLinksToSourceOnly() extension cleans these up before display.

arcology.app.ui.components.NodeBreadcrumb

kotlin#+name: editor-components-nodebreadcrumb:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/NodeBreadcrumb.kt
package computer.whatthefuck.arcology.app.ui.components

import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.NavigateNext
import androidx.compose.material.icons.filled.Description
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.editor.BreadcrumbEntry

/**
 * Breadcrumb navigation showing parent node hierarchy.
 * Each parent node with an id is clickable to navigate/widen scope to that node.
 * Id-less headings are displayed but not clickable.
 *
 * @param parents List of breadcrumb entries from root to immediate parent
 * @param currentTitle Title of the current node (shown at the end, not clickable)
 * @param onParentClick Called when a parent breadcrumb with an id is clicked
 */
@Composable
fun NodeBreadcrumb(
    parents: List<BreadcrumbEntry>,
    currentTitle: String,
    onParentClick: (nodeId: String) -> Unit,
    modifier: Modifier = Modifier
) {
    if (parents.isEmpty()) {
        return
    }

    Surface(
        modifier = modifier.fillMaxWidth(),
        color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
        tonalElevation = 1.dp
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .horizontalScroll(rememberScrollState())
                .padding(horizontal = 12.dp, vertical = 8.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            parents.forEachIndexed { index, entry ->
                if (index > 0) {
                    BreadcrumbSeparator()
                }
                val formattedTitle = entry.title.stripLinksToSourceOnly()
                val entryNodeId = entry.nodeId
                if (entryNodeId != null) {
                    Text(
                        text = formattedTitle,
                        style = MaterialTheme.typography.labelMedium,
                        color = MaterialTheme.colorScheme.primary,
                        modifier = Modifier
                            .clickable(onClick = { onParentClick(entryNodeId) })
                            .padding(vertical = 4.dp)
                    )
                } else {
                    Text(
                        text = formattedTitle,
                        style = MaterialTheme.typography.labelMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        modifier = Modifier.padding(vertical = 4.dp)
                    )
                }
            }

            BreadcrumbSeparator()
            val formattedCurrentTitle = currentTitle.stripLinksToSourceOnly()
            Text(
                text = formattedCurrentTitle,
                style = MaterialTheme.typography.labelMedium,
                fontWeight = FontWeight.SemiBold,
                color = MaterialTheme.colorScheme.onSurface
            )
        }
    }
}

@Composable
private fun BreadcrumbSeparator() {
    Icon(
        imageVector = Icons.AutoMirrored.Filled.NavigateNext,
        contentDescription = null,
        modifier = Modifier.size(16.dp),
        tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
    )
}

/**
 * Compact breadcrumb showing just the outline path (for space-constrained UIs).
 */
@Composable
fun CompactBreadcrumb(
    outlinePath: List<String>,
    onPathClick: ((index: Int) -> Unit)? = null,
    modifier: Modifier = Modifier
) {
    if (outlinePath.isEmpty()) return

    Row(
        modifier = modifier
            .horizontalScroll(rememberScrollState())
            .padding(horizontal = 8.dp, vertical = 4.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        outlinePath.forEachIndexed { index, title ->
            if (index > 0) {
                Text(
                    text = " / ",
                    style = MaterialTheme.typography.labelSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
                )
            }

            val isClickable = onPathClick != null && index < outlinePath.size - 1
            val formattedTitle = title.stripLinksToSourceOnly()
            Text(
                text = formattedTitle,
                style = MaterialTheme.typography.labelSmall,
                color = if (isClickable) {
                    MaterialTheme.colorScheme.primary
                } else {
                    MaterialTheme.colorScheme.onSurfaceVariant
                },
                fontWeight = if (index == outlinePath.size - 1) FontWeight.Medium else FontWeight.Normal,
                modifier = if (isClickable) {
                    Modifier.clickable { onPathClick?.invoke(index) }
                } else {
                    Modifier
                }
            )
        }
    }
}

BacklinksPanel

A collapsible two-section panel:

  1. BacklinkItems — nodes that explicitly link to the current node (via =...= links)

  2. ContentMatchItems — nodes whose body text mentions the current node's title (via FTS search)

Items are combined into a scrollable LazyColumn with section headers. Clicking any item navigates to that node.

arcology.app.ui.components.BacklinksPanel

ImageRenderer

Reads an OrgAttachment from AndroidFileSystem, decodes the bitmap, and displays it scaled to fit the device width. Handles three states:

  1. Loading — spinner

  2. Success — full-width image with rounded corners

  3. Error — compact error indicator

Non-image attachments (video, file) show a ImagePlaceholder with type label.

arcology.app.ui.components.ImageRenderer

kotlin#+name: editor-components-imagerenderer:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/components/ImageRenderer.kt
package computer.whatthefuck.arcology.app.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.domain.AttachmentType
import computer.whatthefuck.arcology.domain.OrgAttachment
import computer.whatthefuck.arcology.indexer.AndroidFileSystem
import android.graphics.BitmapFactory
import android.graphics.Bitmap
import android.widget.ImageView
import androidx.compose.foundation.Image

/**
 * Renders an image attachment inline in the editor read mode.
 * The image is scaled to fit the device width while maintaining aspect ratio.
 *
 * @param attachment The attachment to render
 * @param fileSystem The AndroidFileSystem to read the image from
 * @param modifier Modifier for the root composable
 * @param contentDescription Optional content description for accessibility
 */
@Composable
fun ImageRenderer(
    attachment: OrgAttachment,
    fileSystem: AndroidFileSystem,
    modifier: Modifier = Modifier,
    contentDescription: String? = null
) {
    val context = LocalContext.current

    // Use a remember state to cache the image bitmap
    var isLoading by remember { mutableStateOf(true) }
    var errorMessage by remember { mutableStateOf<String?>(null) }
    var imageBitmap by remember { mutableStateOf<Bitmap?>(null) }

    // Extract filename from resolved path for content description
    val defaultDescription = remember(attachment.resolvedPath) {
        attachment.resolvedPath.substringAfterLast("/")
    }

    // Determine if this is an image attachment
    val isImage = attachment.type == AttachmentType.IMAGE

    if (!isImage) {
        // For non-image attachments, show a placeholder
        ImagePlaceholder(
            filename = attachment.resolvedPath.substringAfterLast("/"),
            attachmentType = attachment.type,
            modifier = modifier.fillMaxWidth()
        )
        return
    }

    // Load the image when the attachment changes
    LaunchedEffect(attachment.resolvedPath, fileSystem) {
        isLoading = true
        errorMessage = null
        imageBitmap = null

        try {
            // Read the file as bytes and decode as bitmap
            val imageData = fileSystem.readFileBytes(attachment.resolvedPath)
            val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size)
            if (bitmap != null) {
                imageBitmap = bitmap
                isLoading = false
            } else {
                errorMessage = "Failed to decode image"
                isLoading = false
            }
        } catch (e: Exception) {
            errorMessage = "Failed to load image: ${e.message}"
            isLoading = false
        }
    }

    Box(
        modifier = modifier
            .fillMaxWidth()
            .heightIn(min = 48.dp),
        contentAlignment = Alignment.Center
    ) {
        when {
            errorMessage != null -> {
                // Error state
                ImageErrorState(
                    message = errorMessage ?: "Failed to load image",
                    filename = attachment.resolvedPath.substringAfterLast("/"),
                    modifier = modifier.fillMaxWidth()
                )
            }

            imageBitmap != null -> {
                // Image loaded successfully - show with Image composable
                Image(
                    bitmap = imageBitmap!!.asImageBitmap(),
                    contentDescription = contentDescription ?: defaultDescription,
                    contentScale = ContentScale.Fit,
                    modifier = modifier
                        .fillMaxWidth()
                        .clip(RoundedCornerShape(4.dp))
                )
            }

            else -> {
                // Loading state
                CircularProgressIndicator(
                    modifier = Modifier.size(24.dp)
                )
            }
        }
    }
}

/**
 * Placeholder for video or file attachments.
 */
@Composable
fun ImagePlaceholder(
    filename: String,
    attachmentType: AttachmentType,
    modifier: Modifier = Modifier
) {
    Box(
        modifier = modifier
            .fillMaxWidth()
            .heightIn(min = 48.dp),
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .heightIn(min = 64.dp)
                .clip(RoundedCornerShape(4.dp))
                .background(
                    MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
                ),
            contentAlignment = Alignment.Center
        ) {
            when (attachmentType) {
                AttachmentType.VIDEO -> {
                    Text(
                        text = "Video: $filename",
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
                AttachmentType.FILE -> {
                    Text(
                        text = "File: $filename",
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
                AttachmentType.IMAGE -> {
                    // This shouldn't happen, but show filename as fallback
                    Text(
                        text = filename,
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
            }
        }
    }
}

/**
 * Error state for failed image loads.
 * Shows a compact Material icon indicator.
 */
@Composable
fun ImageErrorState(
    message: String,
    filename: String,
    modifier: Modifier = Modifier
) {
    Box(
        modifier = modifier
            .fillMaxWidth()
            .heightIn(min = 48.dp),
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .size(32.dp)
                .clip(RoundedCornerShape(4.dp))
                .background(
                    MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
                ),
            contentAlignment = Alignment.Center
        ) {
            Icon(
                imageVector = Icons.Default.BrokenImage,
                contentDescription = "Image failed to load",
                modifier = Modifier.size(24.dp),
                tint = MaterialTheme.colorScheme.onErrorContainer
            )
        }
    }
}

CaptureSettingsScreen

Provides configuration for:

  • Capture Subdirectory — relative path within the org-roam root where captures are saved (e.g., journal/daily)

  • Capture Location — toggle for adding GEO_COORDS property to new captures

  • Capture Templates — navigation to the template settings screen

arcology.app.ui.screens.CaptureSettingsScreen

kotlin#+name: editor-components-capturesettings:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/screens/CaptureSettingsScreen.kt
package computer.whatthefuck.arcology.app.ui.screens

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.app.data.AppPreferences
import org.koin.compose.koinInject

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CaptureSettingsScreen(
    onNavigateToTemplates: () -> Unit = {},
    onNavigateUp: () -> Unit = {}
) {
    val appPreferences: AppPreferences = koinInject()

    var captureSubdirectory by remember { mutableStateOf(appPreferences.getCaptureSubdirectory()) }
    var captureLocationEnabled by remember { mutableStateOf(appPreferences.isCaptureLocationEnabled()) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
    ) {
        // Top app bar
        TopAppBar(
            title = { Text("Capture Settings") },
            navigationIcon = {
                IconButton(onClick = onNavigateUp) {
                    Icon(Icons.Default.ArrowBack, contentDescription = "Back")
                }
            }
        )

        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            // Capture Templates
            Card(
                modifier = Modifier.fillMaxWidth()
            ) {
                ListItem(
                    headlineContent = { Text("Capture Templates") },
                    supportingContent = { Text("Create and manage capture templates") },
                    leadingContent = {
                        Icon(Icons.Default.Article, contentDescription = null)
                    },
                    trailingContent = {
                        Icon(
                            Icons.AutoMirrored.Filled.KeyboardArrowRight,
                            contentDescription = "Open"
                        )
                    },
                    modifier = Modifier.clickable(onClick = onNavigateToTemplates)
                )
            }

            // Capture Subdirectory
            Card(
                modifier = Modifier.fillMaxWidth()
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    Text("Capture Subdirectory", style = MaterialTheme.typography.titleMedium)

                    OutlinedTextField(
                        value = captureSubdirectory,
                        onValueChange = { captureSubdirectory = it },
                        label = { Text("Subdirectory") },
                        supportingText = { Text("Relative path within capture directory, e.g., 'journal/daily'") },
                        singleLine = true,
                        modifier = Modifier.fillMaxWidth()
                    )

                    Button(
                        onClick = {
                            appPreferences.setCaptureSubdirectory(captureSubdirectory)
                        },
                        modifier = Modifier.fillMaxWidth()
                    ) {
                        Text("Save")
                    }
                }
            }

            // Location settings
            Card(
                modifier = Modifier.fillMaxWidth()
            ) {
                Column(
                    modifier = Modifier.padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    Text("Location", style = MaterialTheme.typography.titleMedium)

                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.SpaceBetween,
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Row(
                            verticalAlignment = Alignment.CenterVertically
                        ) {
                            Icon(
                                imageVector = Icons.Default.LocationOn,
                                contentDescription = null,
                                modifier = Modifier.size(24.dp)
                            )
                            Spacer(Modifier.width(8.dp))
                            Column {
                                Text("Capture location")
                                Text(
                                    text = "Add GEO_COORDS property to entries",
                                    style = MaterialTheme.typography.bodySmall,
                                    color = MaterialTheme.colorScheme.onSurfaceVariant
                                )
                            }
                        }
                        Switch(
                            checked = captureLocationEnabled,
                            onCheckedChange = { enabled ->
                                captureLocationEnabled = enabled
                                appPreferences.setCaptureLocationEnabled(enabled)
                            }
                        )
                    }
                }
            }
        }
    }
}

TemplateSettingsScreen — Capture Template CRUD

Full CRUD screen for capture templates. Lists existing templates with edit/delete actions, a FAB to create new ones, two default template picker dropdowns (one for general capture, one for share intents), and an edit dialog with all template fields (name, title/body patterns, tags, TODO state, subdirectory, create-ID toggle). Templates are read from and written to AppPreferences (captureTemplates state flow, saveCaptureTemplate, deleteCaptureTemplate, setDefaultCaptureTemplateId, setDefaultShareTemplateId).

Design decision: the edit dialog is a 7-field AlertDialog with an inline form — no separate ViewModel. Template CRUD is simple enough that pushing it through a ViewModel would add boilerplate without benefit. The AppPreferences interface provides synchronous read/write for templates, and the list recomposes via collectAsState.

arcology.app.ui.screens.TemplateSettingsScreen

kotlin#+name: editor-components-templatesettings:tangle ../app/src/main/kotlin/computer/whatthefuck/arcology/app/ui/screens/TemplateSettingsScreen.kt
package computer.whatthefuck.arcology.app.ui.screens

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import computer.whatthefuck.arcology.app.data.AppPreferences
import computer.whatthefuck.arcology.app.data.CaptureTemplate
import org.koin.compose.koinInject

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TemplateSettingsScreen(
    onBack: () -> Unit
) {
    val appPreferences: AppPreferences = koinInject()
    val templates by appPreferences.captureTemplates.collectAsState(
        initial = appPreferences.getCaptureTemplates()
    )

    var editingTemplate by remember { mutableStateOf<CaptureTemplate?>(null) }
    var showDeleteConfirm by remember { mutableStateOf<CaptureTemplate?>(null) }

    // Default template selection
    val defaultTemplateId = appPreferences.getDefaultCaptureTemplateId()
    var showDefaultTemplatePicker by remember { mutableStateOf(false) }

    // Share template selection
    val shareTemplateId = appPreferences.getDefaultShareTemplateId()
    var showShareTemplatePicker by remember { mutableStateOf(false) }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Capture Templates") },
                navigationIcon = {
                    IconButton(onClick = onBack) {
                        Icon(
                            imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                            contentDescription = "Back"
                        )
                    }
                }
            )
        },
        floatingActionButton = {
            FloatingActionButton(
                onClick = { editingTemplate = CaptureTemplate(name = "") }
            ) {
                Icon(Icons.Default.Add, contentDescription = "Add template")
            }
        }
    ) { paddingValues ->
        if (templates.isEmpty()) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues),
                contentAlignment = Alignment.Center
            ) {
                Text("No templates. Tap + to create one.")
            }
        } else {
            LazyColumn(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues)
            ) {
                items(templates) { template ->
                    TemplateListItem(
                        template = template,
                        onEdit = { editingTemplate = template },
                        onDelete = { showDeleteConfirm = template }
                    )
                }
            }
        }

        // Default template selection
        if (templates.size > 1) {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues)
                    .padding(16.dp)
            ) {
                Text("Default Template", style = MaterialTheme.typography.titleMedium)
                Text("Template used for generic capture (tap +)", style = MaterialTheme.typography.bodySmall)
                Spacer(modifier = Modifier.height(8.dp))
                OutlinedButton(
                    onClick = { showDefaultTemplatePicker = true },
                    modifier = Modifier.fillMaxWidth()
                ) {
                    val defaultTemplate = templates.find { it.id == defaultTemplateId }
                                Text(defaultTemplate?.name ?: "Select default template")
                                Spacer(modifier = Modifier.width(8.dp))
                                Icon(
                                    imageVector = Icons.Default.ArrowDropUp,
                                    contentDescription = null
                                )
                }

                Spacer(modifier = Modifier.height(16.dp))

                Text("Share Template", style = MaterialTheme.typography.titleMedium)
                Text("Template used when sharing URLs or pages from other apps", style = MaterialTheme.typography.bodySmall)
                Spacer(modifier = Modifier.height(8.dp))
                OutlinedButton(
                    onClick = { showShareTemplatePicker = true },
                    modifier = Modifier.fillMaxWidth()
                ) {
                    val shareTemplate = templates.find { it.id == shareTemplateId }
                    Text(shareTemplate?.name ?: "Select share template")
                    Spacer(modifier = Modifier.width(8.dp))
                    Icon(
                        imageVector = Icons.Default.ArrowDropUp,
                        contentDescription = null
                    )
                }
            }
        }
    }

    // Default template picker
    if (showDefaultTemplatePicker) {
        DropdownMenu(
            expanded = showDefaultTemplatePicker,
            onDismissRequest = { showDefaultTemplatePicker = false }
        ) {
            templates.forEach { template ->
                DropdownMenuItem(
                    text = { Text(template.name) },
                    onClick = {
                        appPreferences.setDefaultCaptureTemplateId(template.id)
                        showDefaultTemplatePicker = false
                    },
                    trailingIcon = {
                        if (template.id == defaultTemplateId) {
                            Icon(
                                imageVector = Icons.Default.Check,
                                contentDescription = "Selected"
                            )
                        }
                    }
                )
            }
        }
    }

    // Share template picker
    if (showShareTemplatePicker) {
        DropdownMenu(
            expanded = showShareTemplatePicker,
            onDismissRequest = { showShareTemplatePicker = false }
        ) {
            templates.forEach { template ->
                DropdownMenuItem(
                    text = { Text(template.name) },
                    onClick = {
                        appPreferences.setDefaultShareTemplateId(template.id)
                        showShareTemplatePicker = false
                    },
                    trailingIcon = {
                        if (template.id == shareTemplateId) {
                            Icon(
                                imageVector = Icons.Default.Check,
                                contentDescription = "Selected"
                            )
                        }
                    }
                )
            }
        }
    }

    // Edit/Create dialog
    editingTemplate?.let { template ->
        TemplateEditDialog(
            template = template,
            isNew = template.name.isEmpty(),
            onSave = { updated ->
                appPreferences.saveCaptureTemplate(updated)
                editingTemplate = null
            },
            onDismiss = { editingTemplate = null }
        )
    }

    // Delete confirmation
    showDeleteConfirm?.let { template ->
        AlertDialog(
            onDismissRequest = { showDeleteConfirm = null },
            title = { Text("Delete template?") },
            text = { Text("Delete \"${template.name}\"? This cannot be undone.") },
            confirmButton = {
                TextButton(
                    onClick = {
                        appPreferences.deleteCaptureTemplate(template.id)
                        showDeleteConfirm = null
                    }
                ) {
                    Text("Delete", color = MaterialTheme.colorScheme.error)
                }
            },
            dismissButton = {
                TextButton(onClick = { showDeleteConfirm = null }) {
                    Text("Cancel")
                }
            }
        )
    }
}

@Composable
private fun TemplateListItem(
    template: CaptureTemplate,
    onEdit: () -> Unit,
    onDelete: () -> Unit
) {
    ListItem(
        headlineContent = { Text(template.name) },
        supportingContent = {
            Column {
                if (template.titlePattern.isNotBlank()) {
                    Text("Title: ${template.titlePattern}", maxLines = 1)
                }
                if (template.tags.isNotEmpty()) {
                    Text("Tags: ${template.tags.joinToString(", ")}", maxLines = 1)
                }
                if (template.todoState != null) {
                    Text("TODO: ${template.todoState}")
                }
            }
        },
        trailingContent = {
            Row {
                IconButton(onClick = onEdit) {
                    Icon(Icons.Default.Edit, contentDescription = "Edit")
                }
                IconButton(onClick = onDelete) {
                    Icon(
                        Icons.Default.Delete,
                        contentDescription = "Delete",
                        tint = MaterialTheme.colorScheme.error
                    )
                }
            }
        },
        modifier = Modifier.clickable(onClick = onEdit)
    )
    HorizontalDivider()
}

@Composable
private fun TemplateEditDialog(
    template: CaptureTemplate,
    isNew: Boolean,
    onSave: (CaptureTemplate) -> Unit,
    onDismiss: () -> Unit
) {
    var name by remember { mutableStateOf(template.name) }
    var titlePattern by remember { mutableStateOf(template.titlePattern) }
    var bodyPattern by remember { mutableStateOf(template.bodyPattern) }
    var tagsInput by remember { mutableStateOf(template.tags.joinToString(" ")) }
    var todoState by remember { mutableStateOf(template.todoState ?: "") }
    var subdirectory by remember { mutableStateOf(template.subdirectory ?: "") }
    var createId by remember { mutableStateOf(template.createId) }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(if (isNew) "New Template" else "Edit Template") },
        text = {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .verticalScroll(rememberScrollState()),
                verticalArrangement = Arrangement.spacedBy(12.dp)
            ) {
                OutlinedTextField(
                    value = name,
                    onValueChange = { name = it },
                    label = { Text("Name *") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth()
                )

                OutlinedTextField(
                    value = titlePattern,
                    onValueChange = { titlePattern = it },
                    label = { Text("Title Pattern") },
                    supportingText = { Text("Use %t, %T, %u, %U, %d, %c, %l, %^{Label}") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth()
                )

                OutlinedTextField(
                    value = bodyPattern,
                    onValueChange = { bodyPattern = it },
                    label = { Text("Body Pattern") },
                    minLines = 3,
                    maxLines = 5,
                    modifier = Modifier.fillMaxWidth()
                )

                OutlinedTextField(
                    value = tagsInput,
                    onValueChange = { tagsInput = it },
                    label = { Text("Tags (space separated)") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth()
                )

                OutlinedTextField(
                    value = todoState,
                    onValueChange = { todoState = it },
                    label = { Text("TODO State") },
                    placeholder = { Text("e.g., TODO, NEXT") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth()
                )

                OutlinedTextField(
                    value = subdirectory,
                    onValueChange = { subdirectory = it },
                    label = { Text("Subdirectory") },
                    placeholder = { Text("Override capture directory") },
                    singleLine = true,
                    modifier = Modifier.fillMaxWidth()
                )

                Row(
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Checkbox(
                        checked = createId,
                        onCheckedChange = { createId = it }
                    )
                    Text("Create ID (make node)")
                }

                // Help text
                Text(
                    text = "%-escapes: %t (inactive timestamp), %T (active), %u/%U (with time), %d (date), %c (clipboard), %l (location), %page-title (from share intent), %^{Label} (prompt), %% (literal %)",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        },
        confirmButton = {
            TextButton(
                onClick = {
                    if (name.isNotBlank()) {
                        onSave(
                            template.copy(
                                name = name,
                                titlePattern = titlePattern,
                                bodyPattern = bodyPattern,
                                tags = tagsInput.split(" ").filter { it.isNotBlank() },
                                todoState = todoState.ifBlank { null },
                                subdirectory = subdirectory.ifBlank { null },
                                createId = createId
                            )
                        )
                    }
                },
                enabled = name.isNotBlank()
            ) {
                Text("Save")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("Cancel")
            }
        }
    )
}

Related Modules