Zum Inhalt springen

kotlin-expert

Type Skill
Plugin awl-android · v0.0.19
Invoke /awl-android:kotlin-expert
Tools Read, Write, Edit, Bash, Glob, Grep
Source plugins/awl-android/skills/kotlin-expert/SKILL.md

Builds Android and Kotlin Multiplatform features the way the AWL kmp-template does - shared repositories behind …Providing/…Handling interfaces that return AppResult, BaseViewModel with AppDispatchers and combine().stateIn() state, Compose screens on Navigation 3, Koin modules, Kermit logging, BuildKonfig flavors, and SKIE-bridged @Observable iOS ViewModels. Use whenever you add or change a screen, ViewModel, repository, interface, Koin binding, or error path in a KMP or Android project, for example “create viewmodel”, “build screen”, “add repository”, “wire koin”, “handle errors”, “expose to iOS”, “kmp feature”, “compose ui”. NOT for writing or fixing tests - use the testing-android skill for that.

Trigger phrases: create viewmodel · build screen · add repository · wire koin · handle errors · expose to iOS · kmp feature · compose ui

Feature development for the AWL KMP template: shared/ owns logic, repositories, DI and the error model. androidApp/ owns Compose UI, ViewModels and navigation. iosApp/ is a thin SwiftUI shell over the Shared framework. Nothing UI-related is shared.

  1. Find the layer. Shared logic goes to shared/src/commonMain, Android UI and ViewModels to androidApp/src/main, iOS-only accessors to shared/src/iosMain. In the Compose Multiplatform variant (cmp-template) UI and ViewModels also live in shared/commonMain.
  2. Pick the pattern from the table below and read its reference before writing code.
  3. Wire DI. Register repositories in sharedModule, ViewModels in appModule, iOS accessors in KoinHelper.
  4. Compile-check with ./gradlew :androidApp:compileDevDebugKotlin (or :shared:compileKotlinIosSimulatorArm64 when the shared API changed) and hand tests to the testing-android skill.
Task Pattern Read when
New screen Screen + internal Content composable, Screen key in Screens.kt, entry in AppNavigation references/compose-ui.md for previews, Navigation 3, snackbars, events
New ViewModel Extend BaseViewModel(dispatchers), single StateFlow via combine().stateIn() references/viewmodel.md for BaseViewModel internals, local state, iOS @Observable ViewModels
New repository or service …Providing (queries) and …Handling (commands) interfaces, one implementation, binds in Koin references/interfaces.md for naming, Koin bindings, iOS accessors
Anything that can fail Return AppResult<T>, wrap with appResultOf { } references/error-handling.md for AppError cases, combinators, SKIE on iOS
Diagnostics Kermit Logger references/logging.md for severities and what not to log
  • Never throw across the iOS bridge. Public shared API returns AppResult<T> from com.appswithlove.kmp.error; exceptions thrown from Kotlin crash or vanish in Swift. Reserve @Throws for programmer errors.
  • Wrap failures with appResultOf { }, not try/catch or runCatching. It rethrows CancellationException so coroutine cancellation still works, and maps everything else through Throwable.toAppError().
  • ViewModels take AppDispatchers, not a raw CoroutineDispatcher, so tests can pass AppDispatchers(default = testDispatcher, io = testDispatcher).
  • Launch on catchingCoroutineScope, never viewModelScope. The scope carries the dispatcher and the exception handler that funnels crashes into submitError.
  • Depend on interfaces. A ViewModel takes GreetingProviding, not GreetingRepository, so tests can swap in an in-memory fake.
  • One StateFlow per ViewModel, built with combine(...).stateIn(...); local screen state lives in private MutableStateFlows that feed the combine.
  • kotlin.uuid.Uuid, never java.util.UUID; both modules opt in to ExperimentalUuidApi.
  • Per-flavor constants (BASE_URL) come from BuildKonfig in shared/build.gradle.kts; Android-only build info comes from BuildConfig.
  • Match ktlint via .editorconfig (200-char lines, trailing commas in multiline parameter lists, no wildcard imports).
class HomeViewModel(
dispatchers: AppDispatchers,
greetingProvider: GreetingProviding,
private val greetingHandler: GreetingHandling,
) : BaseViewModel(dispatchers) {
private val selectedGreeting = MutableStateFlow("")
val state: StateFlow<HomeState> = combine(
greetingProvider.observeGreetings(),
selectedGreeting,
) { greetings, greeting ->
HomeState(greetings = greetings, selectedGreeting = greeting)
}.stateIn(
scope = catchingCoroutineScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = HomeState.Empty,
)
fun onGreetClick(chosenGreeting: String) {
catchingCoroutineScope.launch {
greetingHandler.greet(chosenGreeting)
.onSuccess { selectedGreeting.value = it }
.onFailure(::submitError)
}
}
}
@Composable
fun HomeScreen(
onOpenDetail: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: HomeViewModel = koinViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
HomeContent(state = state, onGreet = viewModel::onGreetClick, onOpenDetail = onOpenDetail, modifier = modifier)
}
@Composable
internal fun HomeContent(
state: HomeState,
onGreet: (String) -> Unit,
onOpenDetail: (String) -> Unit,
modifier: Modifier = Modifier,
) { /* UI only, no business logic */ }
@Preview
@Composable
private fun HomeContentPreview() {
HomeContent(state = HomeState.Preview, onGreet = {}, onOpenDetail = {})
}
interface GreetingProviding { fun observeGreetings(): Flow<List<String>> }
interface GreetingHandling { suspend fun greet(name: String): AppResult<String> }
class GreetingRepository : GreetingHandling, GreetingProviding {
override suspend fun greet(name: String): AppResult<String> = appResultOf {
require(name.isNotBlank()) { "name must not be blank" }
"Hello, $name!"
}
override fun observeGreetings(): Flow<List<String>> = flowOf(listOf("Hello", "Hola"))
}
val sharedModule = module {
single { AppDispatchers() }
single { GreetingRepository() } binds arrayOf(GreetingProviding::class, GreetingHandling::class)
}
val appModule = module { viewModel { HomeViewModel(get(), get(), get()) } }
Avoid Use instead
try { } catch (e: Exception) { } in shared code appResultOf { }
throw from a public shared function AppResult.Failure(AppError…)
class VM(dispatcher: CoroutineDispatcher) class VM(dispatchers: AppDispatchers)
viewModelScope.launch { } catchingCoroutineScope.launch { }
_state.update { } exposed as asStateFlow() private MutableStateFlow combined into one StateFlow
class VM(repo: GreetingRepository) class VM(provider: GreetingProviding)
Composable without modifier parameter modifier: Modifier = Modifier after required params
Business logic inside a composable Move it to the ViewModel, keep Content render-only
java.util.UUID kotlin.uuid.Uuid
Issue Fix
AppResult or appResultOf unresolved Import from com.appswithlove.kmp.error; shared exposes awl.kmp.core as api
Koin cannot resolve GreetingHandling Bind the implementation with binds arrayOf(...) for every interface it serves
Swift sees AppResult as a class, not an enum shared/build.gradle.kts must export(libs.awl.kmp.core) in the framework block; SKIE only bridges exported types
StateFlow never updates in UI Collect with collectAsStateWithLifecycle(); WhileSubscribed needs an active collector
BuildKonfig picks the wrong flavor Task name (*Dev*/*Prod*) wins, then local.properties, then gradle.properties