Skip to content

Project Requirements

Ali Sadeghi edited this page May 28, 2026 · 7 revisions

Project Requirements

The architectural rules every feature in KMPilot must follow. This is the enforceable layer — the code-reviewer agent (and the reinject-on-compact.sh hook) check these.

New to KMPilot? Start with Getting-Started.

Directory Structure

KMPilot/
├── composeApp/
│   └── src/commonMain/kotlin/{pkg}/kmpilot/
│       ├── initKoin.kt          # contains startKoin + feature initializers
│       └── BaseAppNavHost.kt    # contains the NavHost + calls to NavGraphBuilder extensions
├── core/
│   ├── common/                  # Either, UiState, BaseFeature, setState, ErrorModel
│   ├── data/                    # ApiClient, ErrorConst, network layer
│   └── designsystem/            # X-components (XTheme, XButton, …)
└── feature/
    └── {featurename}/           # lowercase, no hyphens/underscores
        ├── build.gradle.kts
        └── src/commonMain/kotlin/{pkg}/{featurename}/
            ├── data/
            │   ├── model/
            │   ├── remote/
            │   ├── datasource/
            │   └── repository/
            ├── presentation/
            │   ├── {Feature}ViewModel.kt    # flat — no viewmodel/ subdir
            │   ├── {Feature}UiModel.kt
            │   ├── ui/
            │   │   ├── {Feature}Screen.kt   # 5-slot allowlist only (see UI File Organization)
            │   │   ├── {Feature}Utils.kt    # optional — non-@Composable helpers
            │   │   └── components/          # one file per @Composable (incl. {Feature}Content.kt)
            │   └── navigation/
            └── di/
                └── {Feature}Modules.kt

11 Critical Rules

1. Interface + Impl

//
interface ProductRemoteDataSource {
    suspend fun getProducts(): Either<List<Product>>
}
class ProductRemoteDataSourceImpl(...) : ProductRemoteDataSource { ... }

//
class ProductRemoteDataSource(...) { ... }

2. Either<T> for fallible operations — never throw

//
suspend fun login(req: LoginRequest): Either<LoginResponse>

//
suspend fun login(req: LoginRequest): LoginResponse
suspend fun login(req: LoginRequest): Result<LoginResponse>

3. setState { copy() }

//
_uiModel.setState { copy(isLoading = true) }

//
_uiModel.value = _uiModel.value.copy(isLoading = true)

4. 4 UI States — Uninitialized / Loading / Success / Failed

The four states come from the generic UiState<T> in :core:common — there is no per-feature *UiState.kt (see Rule 11). Each async operation gets one UiState<DTO> slot on the *UiModel, and the ViewModel must handle all four cases:

// In core/common — provided, not regenerated per feature:
sealed interface UiState<out T> {
    data object Uninitialized : UiState<Nothing>
    data object Loading       : UiState<Nothing>
    data class  Success<T>(val data: T) : UiState<T>
    data class  Failed(val error: ErrorModel) : UiState<Nothing>
}

// In the feature — DTO-typed slot on the single UiModel:
val loginState: UiState<LoginResponse> = UiState.Uninitialized

5. X-components only in features (no Material3)

//
XScaffold(title = { XText("Login") }) {
    XTextField(value = "", onValueChange = {})
    XButton(onClick = {}, text = "Login")
}

//
Scaffold(topBar = { TopAppBar(title = { Text("Login") }) }) {
    TextField(...); Button(...) { Text(...) }
}

MaterialTheme.colorScheme and MaterialTheme.typography are allowed (XTheme wraps MaterialTheme). Only component imports are forbidden.

6. ImmutableList for collections

//
data class ProductListUiModel(val products: ImmutableList<Product> = persistentListOf())
setState { copy(products = products.toImmutableList()) }

7. Lowercase package names

//
package thisissadeghi.productdetail

// ❌  thisissadeghi.product-detail
// ❌  thisissadeghi.productDetail
// ❌  thisissadeghi.product_detail

8. DI Pattern — object … : BaseFeature with initialize()

object DashboardModules : BaseFeature(DashboardModules::class.simpleName.toString()) {
    override fun getKoinModules(): List<Module> =
        listOf(
            module {
                singleOf(::DashboardRemoteDataSourceImpl).bind<DashboardRemoteDataSource>()
                singleOf(::DashboardRepositoryImpl).bind<DashboardRepository>()
                viewModelOf(::DashboardViewModel)
            },
        )

    override fun initialize() { DashboardModules }
}

Registration in initKoin.kt:

private fun initializeFeatures() {
    CommonModules.initialize()
    DataModules.initialize()
    DashboardModules.initialize()
    // ... etc.
}

9. No UseCases

ViewModels invoke repositories directly. No UseCase / Interactor layer.

10. Callback parameters in Screens (not navController)

//
@Composable
fun DashboardScreen(
    viewModel: DashboardViewModel = koinViewModel(),
    onActionClick: (String) -> Unit,
    onBackToDashboard: () -> Unit,
) { ... }

//
@Composable
fun DashboardScreen(navController: NavController) { ... }

11. Single UiModel + DTO-wrapped UiState

*UiModel is the only presentation state container — there is no *UiState.kt file. It holds plain UI fields plus one UiState<DTO> slot per async operation, where the DTO is the data-layer model (use UiState<Unit> for void ops). The repository returns Either<DTO> directly; the data layer never imports from presentation. UI-derived display values are sibling fields on *UiModel, never mirror DTO types.

// ✅ one container, DTOs inside UiState
data class DashboardUiModel(
    val searchQuery: String = "",                                     // plain UI field
    val dataState: UiState<DashboardResponse> = UiState.Uninitialized, // UiState<DTO> from data/model/
    val submitState: UiState<Unit> = UiState.Uninitialized,           // UiState<Unit> for void ops
)

// ❌ separate *UiState.kt file, or presentation-layer mirror of a DTO

4 Integration Points

Every new feature wires through exactly these 4:

// 1. settings.gradle.kts
include(":feature:dashboard")

// 2. composeApp/build.gradle.kts
dependencies {
    implementation(project(":feature:dashboard"))
}

// 3. initKoin.kt
private fun initializeFeatures() {
    // ...
    DashboardModules.initialize()
}

// 4. BaseAppNavHost.kt
XNavHost(navController = navController, startDestination = DashboardRoute) {
    dashboard(
        onActionClick = { id -> /* ... */ },
        onBackToDashboard = { navController.popBackStack(DashboardRoute, false) },
    )
}

Navigation Pattern (Type-Safe + Callbacks)

Routes contain only data; callbacks live in the NavGraphBuilder.{featurename}() extension:

@Serializable
data object DashboardRoute

fun NavGraphBuilder.dashboard(
    onActionClick: (String) -> Unit,
    onBackToDashboard: () -> Unit,
) {
    composable<DashboardRoute> {
        DashboardScreen(
            viewModel = koinViewModel(),
            onActionClick = onActionClick,
            onBackToDashboard = onBackToDashboard,
        )
    }
}

BaseAppNavHost.kt just calls dashboard(...) — never composable<Route> { ... } directly.

Module Dependencies

A feature depends on When
:core:common Always (Either, UiState, setState, ErrorModel)
:core:designsystem Always (X-components)
:core:data Only if using ApiClient

Features NEVER depend on other features.

Ktor Resources (Type-Safe API Routes)

//
@Resource("/products")
class ProductResource {
    @Resource("{id}") data class Id(val parent: ProductResource, val id: String)
}
val list   = client.get(ProductResource())
val single = client.get(ProductResource.Id(ProductResource(), "123"))

//
client.get("/products")
client.get("/products/123")

ScreenRoot Pattern

// Screen — ViewModel wrapper (NOT tested)
@Composable
fun FeatureScreen(viewModel: FeatureViewModel, onBackClick: () -> Unit) {
    val uiModel by viewModel.uiModel.collectAsStateWithLifecycle()
    FeatureScreenRoot(uiModel, onBackClick, viewModel::retry)
}

// ScreenRoot — ViewModel-independent (TESTABLE)
@Composable
fun FeatureScreenRoot(uiModel: FeatureUiModel, onBackClick: () -> Unit, onRetry: () -> Unit) {
    // X-components only; route on uiModel.{slot}State for async slots
}

UI tests target ScreenRoot. See Test-UI.

UI File Organization (Strict Allowlist)

{Feature}Screen.kt has a fixed allowlist of top-level @Composable fun declarations — nothing else is permitted at file scope (this is a lint-checked structural rule, not a judgment call):

# Name Visibility Required?
1 {Feature}Screen public Always — ViewModel wrapper
2 {Feature}ScreenRoot public Always — owns state routing
3 LoadingContent private Optional — only if the design specifies a dedicated loading screen
4 FailedContent private Optional — only if the design specifies a dedicated failure screen
5 EmptyContent private Optional — only if the design specifies a dedicated empty screen

Everything else lives one file per composable under presentation/ui/components/ — including {Feature}Content.kt (the success-state / form composable), which is never inlined into Screen.kt.

  • Non-@Composable helpers (formatters, validators, mappers) go in presentation/ui/{Feature}Utils.kt, never under components/.
  • @Preview composables live in the same file as the composable they preview (marked private, wrapped in XTheme) and are exempt from the allowlist. Use the import androidx.compose.ui.tooling.preview.Preview (CMP 1.11.0+, available from commonMain) — the older org.jetbrains.compose.ui.tooling.preview.Preview is deprecated.

Where to Find the Source of Truth

The single canonical file is .claude/skills/_shared/patterns.md. Every skill and agent imports it. This wiki page mirrors that file in a friendlier format — when in doubt, the patterns.md file wins.


Back to Home

Clone this wiki locally