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
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:
BacklinkItems— nodes that explicitly link to the current node (via =...= links)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
package computer.whatthefuck.arcology.app.ui.components
import androidx.compose.animation.AnimatedVisibility
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.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
,* Represents a backlink item for display.
,*/
data class BacklinkItem(
val nodeId: String,
val title: String,
val file: String
)
/**
,* Represents a content match from FTS search.
,*/
data class ContentMatchItem(
val nodeId: String,
val title: String,
val file: String,
val rank: Double
)
/**
,* Collapsible panel showing backlinks and content matches to the current node.
,*/
@Composable
fun BacklinksPanel(
backlinks: List<BacklinkItem>,
contentMatches: List<ContentMatchItem> = emptyList(),
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
onBacklinkClick: (nodeId: String) -> Unit,
modifier: Modifier = Modifier
) {
if (backlinks.isEmpty() && contentMatches.isEmpty()) {
return
}
val totalCount = backlinks.size + contentMatches.size
Column(modifier = modifier.fillMaxWidth()) {
// Header row
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 1.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onExpandedChange(!expanded) }
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Related ($totalCount)",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Combined list
AnimatedVisibility(visible = expanded) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)
) {
// Backlinks section
if (backlinks.isNotEmpty()) {
item {
Text(
text = "Backlinks (${backlinks.size})",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
items(backlinks, key = { "backlink-${it.nodeId}" }) { backlink ->
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onBacklinkClick(backlink.nodeId) }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = backlink.title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = backlink.file,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
HorizontalDivider()
}
}
// Content matches section
if (contentMatches.isNotEmpty()) {
item {
Text(
text = "Mentions (${contentMatches.size})",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
items(contentMatches, key = { "content-${it.nodeId}" }) { match ->
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onBacklinkClick(match.nodeId) }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = match.title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = match.file,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
HorizontalDivider()
}
}
}
}
}
}ImageRenderer
Reads an OrgAttachment from AndroidFileSystem, decodes the bitmap, and displays it scaled to fit the device width. Handles three states:
Loading — spinner
Success — full-width image with rounded corners
Error — compact error indicator
Non-image attachments (video, file) show a ImagePlaceholder with type label.
arcology.app.ui.components.ImageRenderer
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 addingGEO_COORDSproperty to new capturesCapture Templates— navigation to the template settings screen
arcology.app.ui.screens.CaptureSettingsScreen
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
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
Uses
BreadcrumbEntryfrom editor.org (library layer)Uses
AndroidFileSystemfrom indexer.orgConsumed by screen.org (ReadOnlyView)
Consumed by bootstrap.org (CaptureSettingsScreen and TemplateSettingsScreen routes)
CaptureTemplatedata model from app/data.orgTemplate expansion logic from capture-core.org