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 +