diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 9a33d92..1cf9f0d 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -66,5 +66,6 @@ dependencies {
implementation(platform("androidx.compose:compose-bom:2024.09.02"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
-
+ implementation("androidx.glance:glance-appwidget:1.1.1")
+ implementation("androidx.glance:glance-material3:1.1.1")
}
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index eaea59d..923ead8 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -29,5 +29,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt
new file mode 100644
index 0000000..9f724f0
--- /dev/null
+++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureActivity.kt
@@ -0,0 +1,303 @@
+// Copyright (c) 2026 Tyler Stapler
+// SPDX-License-Identifier: Elastic-2.0
+// https://www.elastic.co/licensing/elastic-license
+
+package dev.stapler.stelekit
+
+import android.content.ComponentName
+import android.content.Intent
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import dev.stapler.stelekit.app.R
+import dev.stapler.stelekit.tile.CaptureTileService
+import dev.stapler.stelekit.ui.NoGraphPlaceholderContent
+import dev.stapler.stelekit.ui.theme.StelekitTheme
+import dev.stapler.stelekit.ui.theme.StelekitThemeMode
+
+/**
+ * Lightweight translucent overlay for quick note capture.
+ * Launched from the home screen widget, Quick Settings Tile, and Android share sheet.
+ * Writes to today's journal page via DatabaseWriteActor + GraphWriter.
+ */
+class CaptureActivity : ComponentActivity() {
+
+ private val viewModel: CaptureViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val app = application as SteleKitApplication
+
+ // Task 1.3: parse share intent before setContent (EXTRA_STREAM copy is synchronous)
+ if (savedInstanceState == null) {
+ val shareContent = parseShareIntent(intent)
+ if (shareContent.imageLocalPath != null) {
+ viewModel.initializeText("[image: ${shareContent.imageLocalPath}]\n${shareContent.text}".trim())
+ } else {
+ viewModel.initializeText(shareContent.text)
+ }
+ }
+
+ setContent {
+ StelekitTheme(themeMode = StelekitThemeMode.SYSTEM) {
+ if (app.graphManager?.getActiveRepositorySet() == null) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ NoGraphPlaceholderContent()
+ }
+ } else {
+ CaptureScreen(
+ viewModel = viewModel,
+ onSaved = {
+ // Task 2.2: prompt tile add on first save
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ promptAddTileOnce()
+ }
+ finish()
+ },
+ onDismiss = { finish() },
+ )
+ }
+ }
+ }
+ }
+
+ // Task 1.3: re-parse share extras when singleTop brings this Activity to front
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ val shareContent = parseShareIntent(intent)
+ if (shareContent.imageLocalPath != null) {
+ viewModel.initializeText("[image: ${shareContent.imageLocalPath}]\n${shareContent.text}".trim())
+ } else {
+ viewModel.initializeText(shareContent.text)
+ }
+ }
+
+ // Task 1.3: Bug 3 mitigation — read in mandated null-safe order: clipData → EXTRA_TEXT → EXTRA_SUBJECT
+ private fun parseShareIntent(intent: Intent): ShareContent {
+ if (intent.action != Intent.ACTION_SEND && intent.action != Intent.ACTION_SEND_MULTIPLE) {
+ return ShareContent("", null)
+ }
+ val text = intent.clipData?.getItemAt(0)?.coerceToText(this)?.toString()
+ ?: intent.getStringExtra(Intent.EXTRA_TEXT)
+ ?: intent.getStringExtra(Intent.EXTRA_SUBJECT)
+ ?: ""
+
+ // Bug 2 mitigation: copy EXTRA_STREAM synchronously before any coroutine launch
+ val imagePath = if (intent.type?.startsWith("image/") == true) {
+ @Suppress("DEPRECATION")
+ val streamUri = intent.getParcelableExtra(Intent.EXTRA_STREAM)
+ streamUri?.let { copyStreamToPrivateStorage(it) }
+ } else null
+
+ return ShareContent(text, imagePath)
+ }
+
+ private fun copyStreamToPrivateStorage(uri: android.net.Uri): String? = try {
+ val outFile = java.io.File(cacheDir, "share_${System.currentTimeMillis()}.jpg")
+ val copied = contentResolver.openInputStream(uri)?.use { input ->
+ outFile.outputStream().use { output -> input.copyTo(output) }
+ }
+ if (copied != null) outFile.absolutePath else null
+ } catch (_: SecurityException) { null }
+ catch (_: Exception) { null }
+
+ // Task 2.2: prompt at most once after first successful save (API 33+)
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+ private fun promptAddTileOnce() {
+ val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
+ if (prefs.getBoolean(KEY_TILE_PROMPTED, false)) return
+ prefs.edit().putBoolean(KEY_TILE_PROMPTED, true).apply()
+ try {
+ val sbm = getSystemService(android.app.StatusBarManager::class.java)
+ sbm.requestAddTileService(
+ ComponentName(this, CaptureTileService::class.java),
+ getString(R.string.tile_label_capture),
+ Icon.createWithResource(this, R.drawable.ic_tile_capture),
+ mainExecutor,
+ ) { /* result callback — ignored */ }
+ } catch (_: Exception) { /* OS may reject if tile already added or quota exceeded */ }
+ }
+
+ private data class ShareContent(val text: String, val imageLocalPath: String?)
+
+ companion object {
+ private const val PREFS_NAME = "stelekit_capture_prefs"
+ private const val KEY_TILE_PROMPTED = "pref_tile_prompt_shown"
+ }
+}
+
+@Composable
+private fun CaptureScreen(
+ viewModel: CaptureViewModel,
+ onSaved: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val captureText by viewModel.captureText.collectAsState()
+ val saveState by viewModel.saveState.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val focusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(saveState) {
+ when (val state = saveState) {
+ is CaptureViewModel.SaveState.Saved -> onSaved()
+ is CaptureViewModel.SaveState.Error -> {
+ snackbarHostState.showSnackbar(
+ "Save failed — ${state.throwable?.message ?: "unknown error"}"
+ )
+ }
+ else -> {}
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ // Auto-save on back if there is unsaved text
+ BackHandler(
+ enabled = captureText.isNotBlank() && saveState == CaptureViewModel.SaveState.Idle,
+ ) {
+ viewModel.save()
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Translucent dim layer — tapping it dismisses (or saves if text is non-empty)
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.4f))
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ ) {
+ if (captureText.isBlank()) onDismiss() else viewModel.save()
+ },
+ )
+
+ // Bottom-anchored capture sheet
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .align(Alignment.BottomCenter)
+ .navigationBarsPadding()
+ .imePadding()
+ // Consume clicks so they don't propagate to the dim layer
+ .clickable(enabled = false, indication = null, interactionSource = remember { MutableInteractionSource() }) {},
+ shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 8.dp,
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
+ // Drag handle
+ Box(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .size(width = 40.dp, height = 4.dp)
+ .background(
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
+ RoundedCornerShape(2.dp),
+ ),
+ )
+ Spacer(Modifier.height(12.dp))
+
+ Text(
+ text = "Today's Journal",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = captureText,
+ onValueChange = viewModel::updateText,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ placeholder = { Text("Capture a note…") },
+ minLines = 3,
+ maxLines = 8,
+ )
+ Spacer(Modifier.height(12.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(
+ onClick = onDismiss,
+ enabled = saveState != CaptureViewModel.SaveState.Saving,
+ ) { Text("Dismiss") }
+ Spacer(Modifier.width(8.dp))
+ Button(
+ onClick = viewModel::save,
+ enabled = saveState == CaptureViewModel.SaveState.Idle && captureText.isNotBlank(),
+ ) {
+ if (saveState == CaptureViewModel.SaveState.Saving) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary,
+ )
+ } else {
+ Text("Save")
+ }
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+
+ SnackbarHost(
+ hostState = snackbarHostState,
+ modifier = Modifier.align(Alignment.BottomCenter),
+ )
+ }
+}
diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureViewModel.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureViewModel.kt
new file mode 100644
index 0000000..4a71a79
--- /dev/null
+++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/CaptureViewModel.kt
@@ -0,0 +1,114 @@
+// Copyright (c) 2026 Tyler Stapler
+// SPDX-License-Identifier: Elastic-2.0
+// https://www.elastic.co/licensing/elastic-license
+
+package dev.stapler.stelekit
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import dev.stapler.stelekit.db.GraphManager
+import dev.stapler.stelekit.db.GraphWriter
+import dev.stapler.stelekit.model.Block
+import dev.stapler.stelekit.platform.PlatformFileSystem
+import dev.stapler.stelekit.repository.DirectRepositoryWrite
+import dev.stapler.stelekit.util.UuidGenerator
+import kotlinx.coroutines.channels.ClosedSendChannelException
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlin.time.Clock
+
+class CaptureViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val _captureText = MutableStateFlow("")
+ val captureText: StateFlow = _captureText.asStateFlow()
+
+ private val _saveState = MutableStateFlow(SaveState.Idle)
+ val saveState: StateFlow = _saveState.asStateFlow()
+
+ sealed class SaveState {
+ data object Idle : SaveState()
+ data object Saving : SaveState()
+ data object Saved : SaveState()
+ data class Error(val throwable: Throwable?) : SaveState()
+ }
+
+ fun updateText(text: String) {
+ _captureText.value = text
+ }
+
+ /** Sets the initial text only if the field is still empty (idempotent for singleTop re-launch). */
+ fun initializeText(text: String) {
+ if (_captureText.value.isEmpty() && text.isNotEmpty()) {
+ _captureText.value = text
+ }
+ }
+
+ fun save() {
+ val text = _captureText.value.trim()
+ if (text.isEmpty()) return
+
+ val steleApp = getApplication()
+ val graphManager = steleApp.graphManager ?: run {
+ _saveState.value = SaveState.Error(IllegalStateException("No graph configured"))
+ return
+ }
+
+ viewModelScope.launch {
+ _saveState.value = SaveState.Saving
+ val result = performSave(graphManager, steleApp.fileSystem, text)
+ _saveState.value = if (result.isSuccess) SaveState.Saved
+ else SaveState.Error(result.exceptionOrNull())
+ }
+ }
+
+ private suspend fun performSave(
+ graphManager: GraphManager,
+ fileSystem: PlatformFileSystem,
+ text: String,
+ ): Result = runCatching {
+ val repoSet = graphManager.getActiveRepositorySet()
+ ?: error("No active graph — open SteleKit to set up your graph")
+
+ val page = repoSet.journalService.ensureTodayJournal()
+ val graphPath = graphManager.getActiveGraphInfo()?.path
+ ?: error("No active graph path")
+
+ val existingBlocks = repoSet.blockRepository
+ .getBlocksForPage(page.uuid)
+ .first()
+ .getOrThrow()
+
+ val now = Clock.System.now()
+ val newBlock = Block(
+ uuid = UuidGenerator.generateV7(),
+ pageUuid = page.uuid,
+ content = text,
+ position = (existingBlocks.maxOfOrNull { it.position } ?: -1) + 1,
+ createdAt = now,
+ updatedAt = now,
+ )
+
+ // Bug 1 mitigation: catch ClosedSendChannelException from a graph-switch race
+ val writeActor = repoSet.writeActor
+ if (writeActor != null) {
+ try {
+ writeActor.saveBlock(newBlock).getOrThrow()
+ } catch (e: ClosedSendChannelException) {
+ throw IllegalStateException("Graph switched during save — please retry", e)
+ }
+ } else {
+ @OptIn(DirectRepositoryWrite::class)
+ repoSet.blockRepository.saveBlock(newBlock).getOrThrow()
+ }
+
+ // Bug 8 mitigation: flush the Markdown file after every actor write.
+ // Pass writeActor so GraphWriter can persist filePath for newly created journal pages.
+ val writer = GraphWriter(fileSystem, writeActor = repoSet.writeActor)
+ writer.startAutoSave(viewModelScope)
+ writer.savePage(page, existingBlocks + newBlock, graphPath)
+ }
+}
diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/tile/CaptureTileService.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/tile/CaptureTileService.kt
new file mode 100644
index 0000000..95b275b
--- /dev/null
+++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/tile/CaptureTileService.kt
@@ -0,0 +1,57 @@
+// Copyright (c) 2026 Tyler Stapler
+// SPDX-License-Identifier: Elastic-2.0
+// https://www.elastic.co/licensing/elastic-license
+
+package dev.stapler.stelekit.tile
+
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.content.Intent
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import androidx.annotation.RequiresApi
+import dev.stapler.stelekit.CaptureActivity
+import dev.stapler.stelekit.MainActivity
+import dev.stapler.stelekit.SteleKitApplication
+import dev.stapler.stelekit.app.R
+
+@RequiresApi(Build.VERSION_CODES.N)
+class CaptureTileService : TileService() {
+
+ override fun onStartListening() {
+ super.onStartListening()
+ try {
+ qsTile?.apply {
+ state = Tile.STATE_ACTIVE
+ label = getString(R.string.tile_label_capture)
+ icon = Icon.createWithResource(applicationContext, R.drawable.ic_tile_capture)
+ updateTile()
+ }
+ } catch (_: Exception) { /* tile may not be bound */ }
+ }
+
+ @SuppressLint("NewApi") // version-guarded inline below
+ override fun onClick() {
+ super.onClick()
+ val app = applicationContext as? SteleKitApplication
+ val targetClass = if (app?.graphManager?.getActiveRepositorySet() != null) CaptureActivity::class.java
+ else MainActivity::class.java
+ val intent = Intent(this, targetClass).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ // Bug 6 mitigation: use PendingIntent overload on API 34+, Intent overload on older
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ val pendingIntent = PendingIntent.getActivity(
+ this, 0, intent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ startActivityAndCollapse(pendingIntent)
+ } else {
+ @Suppress("DEPRECATION")
+ startActivityAndCollapse(intent)
+ }
+ }
+}
diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidget.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidget.kt
new file mode 100644
index 0000000..f769f90
--- /dev/null
+++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidget.kt
@@ -0,0 +1,124 @@
+// Copyright (c) 2026 Tyler Stapler
+// SPDX-License-Identifier: Elastic-2.0
+// https://www.elastic.co/licensing/elastic-license
+
+package dev.stapler.stelekit.widget
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.provideContent
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.layout.width
+import androidx.glance.text.Text
+import dev.stapler.stelekit.CaptureActivity
+import dev.stapler.stelekit.MainActivity
+import dev.stapler.stelekit.SteleKitApplication
+import dev.stapler.stelekit.app.R
+
+// Bug 4 mitigation: no var fields — all state derived from context at render time
+class CaptureWidget : GlanceAppWidget() {
+
+ override val sizeMode = SizeMode.Responsive(setOf(SMALL_SIZE, MEDIUM_SIZE))
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ provideContent {
+ val ctx = LocalContext.current
+ val hasGraph = (ctx.applicationContext as? SteleKitApplication)?.graphManager?.getActiveRepositorySet() != null
+ val size = LocalSize.current
+
+ if (!hasGraph) {
+ NoGraphContent()
+ } else if (size.width >= MEDIUM_SIZE.width) {
+ MediumContent()
+ } else {
+ SmallContent()
+ }
+ }
+ }
+
+ @Composable
+ private fun SmallContent() {
+ val context = LocalContext.current
+ val captureIntent = Intent(context, CaptureActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ Box(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .clickable(actionStartActivity(captureIntent))
+ .padding(8.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ provider = ImageProvider(R.drawable.ic_tile_capture),
+ contentDescription = context.getString(R.string.widget_capture_button),
+ modifier = GlanceModifier.size(32.dp),
+ )
+ }
+ }
+
+ @Composable
+ private fun MediumContent() {
+ val context = LocalContext.current
+ val captureIntent = Intent(context, CaptureActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ Box(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .clickable(actionStartActivity(captureIntent)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(
+ modifier = GlanceModifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Image(
+ provider = ImageProvider(R.drawable.ic_tile_capture),
+ contentDescription = null,
+ modifier = GlanceModifier.size(24.dp),
+ )
+ Spacer(modifier = GlanceModifier.width(8.dp))
+ Text(text = context.getString(R.string.widget_capture_button))
+ }
+ }
+ }
+
+ @Composable
+ private fun NoGraphContent() {
+ val context = LocalContext.current
+ val mainIntent = Intent(context, MainActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ Box(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .clickable(actionStartActivity(mainIntent))
+ .padding(8.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text = context.getString(R.string.no_graph_action))
+ }
+ }
+
+ companion object {
+ private val SMALL_SIZE = DpSize(40.dp, 40.dp)
+ private val MEDIUM_SIZE = DpSize(110.dp, 40.dp)
+ }
+}
diff --git a/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidgetReceiver.kt b/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidgetReceiver.kt
new file mode 100644
index 0000000..f75c739
--- /dev/null
+++ b/androidApp/src/main/kotlin/dev/stapler/stelekit/widget/CaptureWidgetReceiver.kt
@@ -0,0 +1,33 @@
+// Copyright (c) 2026 Tyler Stapler
+// SPDX-License-Identifier: Elastic-2.0
+// https://www.elastic.co/licensing/elastic-license
+
+package dev.stapler.stelekit.widget
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import dev.stapler.stelekit.SteleKitApplication
+import kotlinx.coroutines.launch
+
+class CaptureWidgetReceiver : GlanceAppWidgetReceiver() {
+
+ override val glanceAppWidget: GlanceAppWidget = CaptureWidget()
+
+ // Bug 7 mitigation: use goAsync() + appScope to avoid GlobalScope and process-kill data loss
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ ) {
+ val pendingResult = goAsync()
+ (context.applicationContext as SteleKitApplication).appScope.launch {
+ try {
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+}
diff --git a/androidApp/src/main/res/drawable/ic_tile_capture.xml b/androidApp/src/main/res/drawable/ic_tile_capture.xml
new file mode 100644
index 0000000..db4ad93
--- /dev/null
+++ b/androidApp/src/main/res/drawable/ic_tile_capture.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/androidApp/src/main/res/layout/widget_initial_layout.xml b/androidApp/src/main/res/layout/widget_initial_layout.xml
new file mode 100644
index 0000000..8d5d827
--- /dev/null
+++ b/androidApp/src/main/res/layout/widget_initial_layout.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/androidApp/src/main/res/values/strings.xml b/androidApp/src/main/res/values/strings.xml
index c923000..9adbcf0 100644
--- a/androidApp/src/main/res/values/strings.xml
+++ b/androidApp/src/main/res/values/strings.xml
@@ -1,4 +1,10 @@
SteleKit
+
+
+ Open SteleKit
+ SteleKit Capture
+ Capture note
+ Quick note capture to today\'s journal
diff --git a/androidApp/src/main/res/values/themes.xml b/androidApp/src/main/res/values/themes.xml
index 7e1674a..4ab8b4a 100644
--- a/androidApp/src/main/res/values/themes.xml
+++ b/androidApp/src/main/res/values/themes.xml
@@ -1,4 +1,12 @@
+
+
+
diff --git a/androidApp/src/main/res/xml/capture_widget_info.xml b/androidApp/src/main/res/xml/capture_widget_info.xml
new file mode 100644
index 0000000..1d77914
--- /dev/null
+++ b/androidApp/src/main/res/xml/capture_widget_info.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/ui/NoGraphPlaceholder.kt b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/ui/NoGraphPlaceholder.kt
new file mode 100644
index 0000000..fa8e89b
--- /dev/null
+++ b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/ui/NoGraphPlaceholder.kt
@@ -0,0 +1,59 @@
+// Copyright (c) 2026 Tyler Stapler
+// SPDX-License-Identifier: Elastic-2.0
+// https://www.elastic.co/licensing/elastic-license
+
+package dev.stapler.stelekit.ui
+
+import android.content.Intent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import dev.stapler.stelekit.R
+
+@Composable
+fun NoGraphPlaceholderContent(modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ Column(
+ modifier = modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = stringResource(R.string.no_graph_title),
+ style = MaterialTheme.typography.titleMedium,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.no_graph_body),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ // Launch the main app's launcher activity without importing the class directly
+ // (kmp module cannot depend on androidApp's MainActivity class)
+ Button(onClick = {
+ val intent = Intent(Intent.ACTION_MAIN).apply {
+ addCategory(Intent.CATEGORY_LAUNCHER)
+ setPackage(context.packageName)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ }
+ context.startActivity(intent)
+ }) {
+ Text(text = stringResource(R.string.no_graph_action))
+ }
+ }
+}
diff --git a/kmp/src/androidMain/res/values/strings.xml b/kmp/src/androidMain/res/values/strings.xml
new file mode 100644
index 0000000..604f183
--- /dev/null
+++ b/kmp/src/androidMain/res/values/strings.xml
@@ -0,0 +1,7 @@
+
+
+
+ No graph selected
+ Open SteleKit to set up your graph
+ Open SteleKit
+