Phase 1 work

This commit is contained in:
2026-04-24 20:21:03 +02:00
parent b36058fa79
commit 68655eae1a
22 changed files with 126 additions and 65 deletions

21
.editorconfig Normal file
View File

@@ -0,0 +1,21 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.{kt,kts}]
# ktlint configuration for Compose Multiplatform.
# - function-naming is disabled because @Composable functions and Kotlin/Native
# entry-point factories (e.g. MainViewController) are PascalCase by convention.
# - filename is disabled because Compose-Multiplatform entry-point files
# (jvmMain/main.kt, webMain/main.kt) follow the Kotlin `fun main()` convention.
ktlint_standard_function-naming = disabled
ktlint_standard_filename = disabled
[*.md]
trim_trailing_whitespace = false

View File

@@ -4,13 +4,13 @@ milestone: v1.0
milestone_name: milestone milestone_name: milestone
current_plan: 1 current_plan: 1
status: executing status: executing
last_updated: "2026-04-24T16:11:23.051Z" last_updated: "2026-04-24T17:39:22.205Z"
progress: progress:
total_phases: 11 total_phases: 11
completed_phases: 0 completed_phases: 0
total_plans: 7 total_plans: 7
completed_plans: 0 completed_plans: 4
percent: 0 percent: 57
--- ---
# Project State: Recipe # Project State: Recipe
@@ -25,11 +25,11 @@ progress:
## Current Position ## Current Position
Phase: 01 (Project Infrastructure & Module Wiring) — EXECUTING Phase: --phase (01) — EXECUTING
Plan: 1 of 7 Plan: 1 of --name
**Current focus:** Phase 01 — Project Infrastructure & Module Wiring **Current focus:** Phase --phase — 01
**Current plan:** 1 **Current plan:** 1
**Status:** Executing Phase 01 **Status:** Executing Phase --phase
**Phase progress:** 0 / 11 phases complete **Phase progress:** 0 / 11 phases complete
**Progress bar:** `░░░░░░░░░░░░░░░░░░░░` 0% **Progress bar:** `░░░░░░░░░░░░░░░░░░░░` 0%

View File

@@ -53,3 +53,17 @@ kotlin {
} }
} }
} }
// Relax allWarningsAsErrors for KLIB-merging metadata tasks. KotlinCompileCommon
// aggregates dependency KLIBs and surfaces upstream "duplicated unique_name"
// resolver warnings caused by androidx.lifecycle 2.10.0 (Android-only) and
// org.jetbrains.androidx.lifecycle 2.10.0 (CMP) co-publishing artifacts with
// matching KLIB unique_names. This is an upstream Compose-Multiplatform 1.10 +
// lifecycle 2.10 ecosystem condition (KT-62515-style), not actionable in our
// source — so we keep -Werror on real source compilation tasks but disable it
// for the metadata-aggregation step where no user code is being compiled.
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompileCommon>().configureEach {
compilerOptions {
allWarningsAsErrors.set(false)
}
}

View File

@@ -1,7 +1,10 @@
plugins { plugins {
// AGP must apply BEFORE recipe.kotlin.multiplatform — the latter calls androidTarget(),
// which requires the Android Gradle Plugin to already be on the project. Gradle applies
// plugin IDs in declaration order, so recipe.android.application is listed first.
id("recipe.android.application")
id("recipe.kotlin.multiplatform") id("recipe.kotlin.multiplatform")
id("recipe.compose.multiplatform") id("recipe.compose.multiplatform")
id("recipe.android.application")
id("recipe.quality") id("recipe.quality")
} }

View File

@@ -22,4 +22,4 @@ class MainActivity : ComponentActivity() {
@Composable @Composable
fun AppAndroidPreview() { fun AppAndroidPreview() {
App() App()
} }

View File

@@ -10,12 +10,15 @@ import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import recipe.composeapp.generated.resources.Res import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.compose_multiplatform import recipe.composeapp.generated.resources.compose_multiplatform
@@ -25,10 +28,11 @@ fun App() {
MaterialTheme { MaterialTheme {
var showContent by remember { mutableStateOf(false) } var showContent by remember { mutableStateOf(false) }
Column( Column(
modifier = Modifier modifier =
.background(MaterialTheme.colorScheme.primaryContainer) Modifier
.safeContentPadding() .background(MaterialTheme.colorScheme.primaryContainer)
.fillMaxSize(), .safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Button(onClick = { showContent = !showContent }) { Button(onClick = { showContent = !showContent }) {
@@ -46,4 +50,4 @@ fun App() {
} }
} }
} }
} }

View File

@@ -3,6 +3,7 @@ package dev.ulfrx.recipe.di
import org.koin.dsl.module import org.koin.dsl.module
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc. // Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule = module { val appModule =
// intentionally empty in Phase 1 module {
} // intentionally empty in Phase 1
}

View File

@@ -4,7 +4,8 @@ import org.koin.core.KoinApplication
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration import org.koin.dsl.KoinAppDeclaration
fun initKoin(config: KoinAppDeclaration? = null): KoinApplication = startKoin { fun initKoin(config: KoinAppDeclaration? = null): KoinApplication =
config?.invoke(this) startKoin {
modules(appModule) config?.invoke(this)
} modules(appModule)
}

View File

@@ -4,9 +4,8 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class ComposeAppCommonTest { class ComposeAppCommonTest {
@Test @Test
fun example() { fun example() {
assertEquals(3, 1 + 2) assertEquals(3, 1 + 2)
} }
} }

View File

@@ -2,4 +2,4 @@ package dev.ulfrx.recipe
import androidx.compose.ui.window.ComposeUIViewController import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController { App() } fun MainViewController() = ComposeUIViewController { App() }

View File

@@ -17,7 +17,9 @@ fun main() {
} }
@Serializable @Serializable
private data class Health(val status: String) private data class Health(
val status: String,
)
fun Application.module() { fun Application.module() {
install(ContentNegotiation) { install(ContentNegotiation) {

View File

@@ -8,14 +8,24 @@ object Database {
private val log = LoggerFactory.getLogger(Database::class.java) private val log = LoggerFactory.getLogger(Database::class.java)
fun migrate(app: Application) { fun migrate(app: Application) {
val url = app.environment.config.property("database.url").getString() val url =
val user = app.environment.config.property("database.user").getString() app.environment.config
val password = app.environment.config.property("database.password").getString() .property("database.url")
.getString()
val user =
app.environment.config
.property("database.user")
.getString()
val password =
app.environment.config
.property("database.password")
.getString()
log.info("Connecting to {} as {} and running Flyway migrations", url, user) log.info("Connecting to {} as {} and running Flyway migrations", url, user)
runCatching { runCatching {
Flyway.configure() Flyway
.configure()
.dataSource(url, user, password) .dataSource(url, user, password)
.locations("classpath:db/migration") .locations("classpath:db/migration")
.baselineOnMigrate(true) .baselineOnMigrate(true)

View File

@@ -12,19 +12,19 @@ import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
class ApplicationTest { class ApplicationTest {
@Test @Test
fun `health endpoint returns 200 with status ok`() = testApplication { fun `health endpoint returns 200 with status ok`() =
application { testApplication {
install(ContentNegotiation) { application {
json() install(ContentNegotiation) {
json()
}
configureRouting()
} }
configureRouting() val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
val body = response.bodyAsText()
assertTrue(body.contains("\"status\""), "expected body to contain status field, was: $body")
assertTrue(body.contains("\"ok\""), "expected body to contain ok value, was: $body")
} }
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
val body = response.bodyAsText()
assertTrue(body.contains("\"status\""), "expected body to contain status field, was: $body")
assertTrue(body.contains("\"ok\""), "expected body to contain ok value, was: $body")
}
} }

View File

@@ -1,7 +1,10 @@
plugins { plugins {
// AGP must apply BEFORE recipe.kotlin.multiplatform — the latter calls androidTarget(),
// which requires the Android Gradle Plugin to already be on the project. Gradle applies
// plugin IDs in declaration order, so com.android.library is listed first.
alias(libs.plugins.androidLibrary)
id("recipe.kotlin.multiplatform") id("recipe.kotlin.multiplatform")
id("recipe.quality") id("recipe.quality")
alias(libs.plugins.androidLibrary)
} }
kotlin { kotlin {
@@ -25,12 +28,18 @@ kotlin {
android { android {
namespace = "dev.ulfrx.recipe.shared" namespace = "dev.ulfrx.recipe.shared"
compileSdk = libs.versions.android.compileSdk.get().toInt() compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
defaultConfig { defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt() minSdk =
libs.versions.android.minSdk
.get()
.toInt()
} }
} }

View File

@@ -2,8 +2,8 @@ package dev.ulfrx.recipe
import android.os.Build import android.os.Build
class AndroidPlatform : Platform { public class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}" override val name: String = "Android ${Build.VERSION.SDK_INT}"
} }
actual fun getPlatform(): Platform = AndroidPlatform() public actual fun getPlatform(): Platform = AndroidPlatform()

View File

@@ -1,3 +1,3 @@
package dev.ulfrx.recipe package dev.ulfrx.recipe
const val SERVER_PORT = 8080 public const val SERVER_PORT: Int = 8080

View File

@@ -1,9 +1,7 @@
package dev.ulfrx.recipe package dev.ulfrx.recipe
class Greeting { public class Greeting {
private val platform = getPlatform() private val platform = getPlatform()
fun greet(): String { public fun greet(): String = "Hello, ${platform.name}!"
return "Hello, ${platform.name}!" }
}
}

View File

@@ -1,7 +1,7 @@
package dev.ulfrx.recipe package dev.ulfrx.recipe
interface Platform { public interface Platform {
val name: String public val name: String
} }
expect fun getPlatform(): Platform public expect fun getPlatform(): Platform

View File

@@ -4,9 +4,8 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class SharedCommonTest { class SharedCommonTest {
@Test @Test
fun example() { fun example() {
assertEquals(3, 1 + 2) assertEquals(3, 1 + 2)
} }
} }

View File

@@ -2,8 +2,8 @@ package dev.ulfrx.recipe
import platform.UIKit.UIDevice import platform.UIKit.UIDevice
class IOSPlatform : Platform { public class IOSPlatform : Platform {
override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
} }
actual fun getPlatform(): Platform = IOSPlatform() public actual fun getPlatform(): Platform = IOSPlatform()

View File

@@ -1,7 +1,7 @@
package dev.ulfrx.recipe package dev.ulfrx.recipe
class JVMPlatform : Platform { public class JVMPlatform : Platform {
override val name: String = "Java ${System.getProperty("java.version")}" override val name: String = "Java ${System.getProperty("java.version")}"
} }
actual fun getPlatform(): Platform = JVMPlatform() public actual fun getPlatform(): Platform = JVMPlatform()

View File

@@ -1,7 +1,7 @@
package dev.ulfrx.recipe package dev.ulfrx.recipe
class WasmPlatform : Platform { public class WasmPlatform : Platform {
override val name: String = "Web with Kotlin/Wasm" override val name: String = "Web with Kotlin/Wasm"
} }
actual fun getPlatform(): Platform = WasmPlatform() public actual fun getPlatform(): Platform = WasmPlatform()