-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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}UiState.kt
│ ├── {Feature}UiModel.kt
│ ├── ui/
│ │ ├── {Feature}Screen.kt # Screen + ScreenRoot + state routing
│ │ └── components/ # self-contained UI units
│ └── navigation/
└── di/
└── {Feature}Modules.kt
// ✅
interface ProductRemoteDataSource {
suspend fun getProducts(): Either<List<Product>>
}
class ProductRemoteDataSourceImpl(...) : ProductRemoteDataSource { ... }
// ❌
class ProductRemoteDataSource(...) { ... }// ✅
suspend fun login(req: LoginRequest): Either<LoginResponse>
// ❌
suspend fun login(req: LoginRequest): LoginResponse
suspend fun login(req: LoginRequest): Result<LoginResponse>// ✅
_uiState.setState { copy(isLoading = true) }
// ❌
_uiState.value = _uiState.value.copy(isLoading = true)// ✅
sealed interface LoginUiState : UiState {
data object Uninitialized : LoginUiState
data object Loading : LoginUiState
data class Success(val token: String) : LoginUiState
data class Failed(val error: ErrorModel) : LoginUiState
}// ✅
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.
// ✅
data class ProductListUiState(val products: ImmutableList<ProductUiModel>)
setState { copy(products = products.toImmutableList()) }// ✅
package thisissadeghi.productdetail
// ❌ thisissadeghi.product-detail
// ❌ thisissadeghi.productDetail
// ❌ thisissadeghi.product_detailobject 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.
}ViewModels invoke repositories directly. No UseCase / Interactor layer.
// ✅
@Composable
fun DashboardScreen(
viewModel: DashboardViewModel = koinViewModel(),
onActionClick: (String) -> Unit,
onBackToDashboard: () -> Unit,
) { ... }
// ❌
@Composable
fun DashboardScreen(navController: NavController) { ... }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) },
)
}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.
| 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.
// ✅
@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")// Screen — ViewModel wrapper (NOT tested)
@Composable
fun FeatureScreen(viewModel: FeatureViewModel, onBackClick: () -> Unit) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
FeatureScreenRoot(uiState, onBackClick, viewModel::retry)
}
// ScreenRoot — ViewModel-independent (TESTABLE)
@Composable
fun FeatureScreenRoot(uiState: UiState, onBackClick: () -> Unit, onRetry: () -> Unit) {
// X-components only
}UI tests target ScreenRoot. See Test-UI.
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