Skip to content

Commit 4615c81

Browse files
committed
feat: implement SQL console feature
- Add `feature:console` with `domain`, `presentation`, and `ui` modules to allow executing raw SQL commands. - Implement `ConsoleUseCase` for input normalization and command execution against the database. - Create `ConsoleViewModel` to manage input state, command history, and transcript results. - Develop a custom `ConsoleSurface` UI component with a terminal-like experience, supporting multi-line input and continuation prompts. - Integrate "SQLite Shell" as a new category in Settings across all platforms (Android, iOS, JVM, WasmJs). - Add "Console tips" menu in the UI with copy and autofill functionality for common SQL commands. - Include unit tests for domain logic and UI transformations, plus integration tests for the new screen. - Bump `agp` to `9.1.1`, `koin-bom` to `4.2.1`, `firebase` to `34.12.0`, `crashlytics` to `3.0.7`, and update `compileSdk`/`targetSdk` to `37`.
1 parent 732be06 commit 4615c81

52 files changed

Lines changed: 2320 additions & 6 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
### Project Structure & Module Organization
1111
- Core: `core/domain`, `core/presentation`, `core/data/db-sqldelight` (default), `core/data/db-room` (optional), `core/test`.
12+
- Features: `feature/backup/{domain,ui}`, `feature/console/{domain,presentation,ui}`, `feature/file-explorer/data`.
1213
- UI: `ui/shared` (common Compose code and resources), `ui/test` (multiplatform Compose UI tests), `ui/test-jvm` (JVM-specific UI test utilities).
1314
- Apps: `app/android`, `app/desktop`, `app/web`, `app/ios-kit` (CocoaPods framework), `app/iosApp` (Xcode project).
1415
- Tooling: `build-logic` (Gradle conventions), `thirdparty` (vendored modules), `gradle/libs.versions.toml` (versions).

app/android/src/androidTest/java/com/softartdev/notedelight/ui/AndroidUiTests.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class AndroidUiTests : AbstractJvmUiTests() {
6767
unloadKoinModules(backupTestModule)
6868
}
6969

70+
@Test
71+
override fun consoleFeatureTest() = super.consoleFeatureTest()
72+
7073
override fun pressBack() = Espresso.pressBack()
7174

7275
override fun closeSoftKeyboard() = Espresso.closeSoftKeyboard()

app/desktop/src/jvmTest/kotlin/com/softartdev/notedelight/ui/DesktopUiTests.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ class DesktopUiTests : AbstractJvmUiTests() {
106106
@Test
107107
override fun backupFeatureTest() = super.backupFeatureTest()
108108

109+
@Test
110+
override fun consoleFeatureTest() = super.consoleFeatureTest()
111+
109112
override fun pressBack() {
110113
val backButtons = composeTestRule.onAllNodesWithContentDescription(
111114
label = Icons.AutoMirrored.Filled.ArrowBack.name
Binary file not shown.

app/web/src/wasmJsTest/kotlin/com/softartdev/notedelight/WebUiTests.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import co.touchlab.kermit.platformLogWriter
1919
import com.softartdev.notedelight.di.sharedModules
2020
import com.softartdev.notedelight.di.uiTestModules
2121
import com.softartdev.notedelight.ui.cases.BackupFeatureTestCase
22+
import com.softartdev.notedelight.ui.cases.ConsoleFeatureTestCase
2223
import com.softartdev.notedelight.ui.cases.CreateNoteWhileSelectedTestCase
2324
import com.softartdev.notedelight.ui.cases.CrudTestCase
2425
import com.softartdev.notedelight.ui.cases.EditTitleAfterCreateTestCase
@@ -139,6 +140,15 @@ class WebUiTests {
139140
).invoke()
140141
}
141142

143+
@Test
144+
fun consoleFeatureTest() = awaitComposeUiTest {
145+
launchApp(composeUiTest = this@awaitComposeUiTest)
146+
ConsoleFeatureTestCase(
147+
composeUiTest = this@awaitComposeUiTest,
148+
pressBack = { clickBack(this@awaitComposeUiTest) },
149+
).invoke()
150+
}
151+
142152
private fun launchApp(composeUiTest: ComposeUiTest) {
143153
val lifecycleOwner = TestLifecycleOwner(initialState = Lifecycle.State.RESUMED)
144154
composeUiTest.setContent {
@@ -173,4 +183,5 @@ class WebUiTests {
173183
@Test override fun settingPasswordTest(): TestResult = super.settingPasswordTest()
174184
@Test override fun localeTest(): TestResult = super.localeTest()
175185
@Test override fun backupFeatureTest(): TestResult = super.backupFeatureTest()
186+
@Test override fun consoleFeatureTest(): TestResult = super.consoleFeatureTest()
176187
}*/

core/domain/src/commonMain/kotlin/com/softartdev/notedelight/model/SettingsCategory.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ enum class SettingsCategory {
44
Appearance,
55
Security,
66
Backup,
7+
Console,
78
Info;
89

910
val id: Long = ordinal.toLong()

feature/console/README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# feature:console
2+
3+
SQL Console feature modules for executing raw SQL commands against the app database.
4+
5+
## Modules
6+
7+
### feature:console:domain
8+
- `ConsoleTranscriptEntryKind``COMMAND`, `OUTPUT`, `STATUS`, `ERROR`
9+
- `ConsoleTranscriptEntry` — domain model for transcript rows
10+
- `ConsoleCommandExecutor` — interface for the data layer to implement
11+
- `ExecuteConsoleCommandUseCase` — orchestrates input validation, semicolon normalization, execution, and error wrapping
12+
13+
Use case behavior:
14+
- Trims input; rejects blank input with `ValidationError("Enter a SQL statement.")`
15+
- Appends `;` if missing
16+
- Wraps thrown exceptions into `ExecutionError` with `throwable.message ?: "Console command failed."`
17+
18+
### feature:console:data
19+
- `SqlDelightConsoleCommandExecutor` — implements `ConsoleCommandExecutor` using `SafeRepo.execute()`
20+
- Queries returning a non-null value produce `OUTPUT` + `STATUS("Query returned 1 row(s).")`
21+
- Statements returning null produce `STATUS("Statement executed successfully.")`
22+
23+
### feature:console:presentation
24+
- `ConsoleViewModel` with `ConsoleResult` / `ConsoleAction` MVI pattern
25+
- Manages `input`, `running`, `transcript`, and `commandHistory` state
26+
- Session-only history (not persisted across restarts)
27+
- Ignores submit while already running
28+
29+
### feature:console:ui
30+
Custom Compose-Multiplatform terminal surface (Android/JVM/iOS/wasmJs).
31+
- `ConsoleSurface` — public composable; the body of the console screen
32+
- `ConsoleBuffer` / `ConsoleBufferBuilder` — read-only scrollback model
33+
- `ConsolePromptVisualTransformation` — adds ` ...> ` continuation prompt after every `\n` in the input
34+
- `ConsoleStatementAnalyzer` — heuristic completeness check (trim → empty false → `.` prefix true → `;` suffix true)
35+
- `ConsoleTheme` / `rememberConsoleTheme()` — Material3-derived palette and typography (no hardcoded colors)
36+
- Internal renderers in `render/`: `ConsoleHistoryCanvas` (Canvas + `TextMeasurer`) and `ConsoleInputRow` (`BasicTextField` + Run button)
37+
38+
The module is resource-free — the surrounding `ConsoleScreen` in `:ui:shared` resolves strings via `stringResource(...)` and passes them as plain parameters. See `feature/console/ui/README.md` for design rationale and roadmap.
39+
40+
## Limitations
41+
42+
`SafeRepo.execute()` returns only the first column of the first row as `String?`. Full result sets are not available through the console.
43+
44+
## UI
45+
46+
The `ConsoleScreen` composable lives in `ui:shared` alongside other settings detail screens. It owns the Scaffold/TopAppBar, tips dropdown, helper text, progress indicator, and Koin view-model wiring; the terminal body itself is delegated to `ConsoleSurface` from `:feature:console:ui`. Together they provide:
47+
- Top app bar with "Console tips" overflow menu
48+
- A custom Canvas-rendered scrollback with theme-aware colors
49+
- Multi-line input with `sqlite>` prompt and ` ...> ` continuation prompt
50+
- Run button preserves the existing test contract
51+
52+
## Testing
53+
54+
```bash
55+
# Domain and data unit tests
56+
./gradlew :feature:console:domain:allTests
57+
./gradlew :feature:console:data:allTests
58+
59+
# ViewModel tests
60+
./gradlew :feature:console:presentation:allTests
61+
62+
# UI module unit tests (buffer, statement analyzer, visual transformation)
63+
./gradlew :feature:console:ui:jvmTest
64+
65+
# Compose UI tests (ConsoleScreen)
66+
./gradlew :ui:test-jvm:jvmTest
67+
68+
# Full build
69+
./gradle/build_quick.sh
70+
```
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
@file:OptIn(ExperimentalWasmDsl::class)
2+
3+
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
4+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
5+
6+
plugins {
7+
alias(libs.plugins.gradle.convention)
8+
alias(libs.plugins.kotlin.multiplatform)
9+
alias(libs.plugins.android.kotlin.multiplatform.library)
10+
}
11+
12+
kotlin {
13+
jvmToolchain(libs.versions.jdk.get().toInt())
14+
jvm()
15+
android {
16+
namespace = "com.softartdev.notedelight.feature.console.domain"
17+
compileSdk = libs.versions.compileSdk.get().toInt()
18+
minSdk = libs.versions.minSdk.get().toInt()
19+
compilerOptions {
20+
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jdk.get()))
21+
}
22+
}
23+
iosArm64()
24+
iosSimulatorArm64()
25+
wasmJs {
26+
browser()
27+
}
28+
sourceSets.forEach {
29+
it.dependencies {
30+
implementation(project.dependencies.enforcedPlatform(libs.coroutines.bom))
31+
}
32+
}
33+
sourceSets {
34+
val commonMain by getting {
35+
dependencies {
36+
implementation(projects.core.domain)
37+
implementation(libs.coroutines.core)
38+
}
39+
}
40+
val commonTest by getting {
41+
dependencies {
42+
implementation(kotlin("test"))
43+
implementation(libs.coroutines.test)
44+
}
45+
}
46+
}
47+
compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes")
48+
}
49+
50+
dependencies {
51+
coreLibraryDesugaring(libs.desugar)
52+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.softartdev.notedelight.usecase.console
2+
3+
enum class ConsoleTranscriptEntryKind {
4+
COMMAND,
5+
OUTPUT,
6+
STATUS,
7+
ERROR,
8+
}
9+
10+
data class ConsoleTranscriptEntry(
11+
val kind: ConsoleTranscriptEntryKind,
12+
val text: String,
13+
)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.softartdev.notedelight.usecase.console
2+
3+
import com.softartdev.notedelight.repository.SafeRepo
4+
5+
class ConsoleUseCase(private val safeRepo: SafeRepo) {
6+
7+
suspend operator fun invoke(rawInput: String): ConsoleUseCaseResult {
8+
val trimmed: String = rawInput.trim()
9+
if (trimmed.isBlank()) return ConsoleUseCaseResult.ValidationError("Enter a SQL statement.")
10+
val sql: String = if (trimmed.endsWith(";")) trimmed else "$trimmed;"
11+
return try {
12+
val entries = when (val result: String? = safeRepo.execute(sql)) {
13+
null -> listOf(
14+
ConsoleTranscriptEntry(
15+
kind = ConsoleTranscriptEntryKind.STATUS,
16+
text = "Statement executed successfully."
17+
),
18+
)
19+
else -> listOf(
20+
ConsoleTranscriptEntry(
21+
kind = ConsoleTranscriptEntryKind.OUTPUT,
22+
text = result
23+
),
24+
ConsoleTranscriptEntry(
25+
kind = ConsoleTranscriptEntryKind.STATUS,
26+
text = "Query returned 1 row(s)."
27+
),
28+
)
29+
}
30+
ConsoleUseCaseResult.Executed(entries = entries, normalizedCommand = sql)
31+
} catch (t: Throwable) {
32+
val msg = t.message ?: "Console command failed."
33+
ConsoleUseCaseResult.Executed(
34+
entries = listOf(ConsoleTranscriptEntry(ConsoleTranscriptEntryKind.ERROR, msg)),
35+
normalizedCommand = sql,
36+
)
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)