Estructura interna del shared module
shared/src/commonMain/kotlin/ar/pensa/app/shared/
├── domain/
│ ├── model/
│ │ ├── Producto.kt
│ │ └── Carrito.kt
│ ├── repository/
│ │ └── ProductoRepository.kt # interfaz
│ └── usecase/
│ └── GetProductosUseCase.kt
├── data/
│ ├── remote/
│ │ ├── ProductoApi.kt # llamadas Ktor
│ │ └── dto/
│ │ └── ProductoDto.kt # modelo de red (con @Serializable)
│ ├── local/
│ │ └── ProductoLocalDataSource.kt # caché (SQLDelight)
│ └── repository/
│ └── ProductoRepositoryImpl.kt
└── di/
└── SharedModule.kt # módulo Koin compartido
Modelos de dominio — Kotlin puro
// commonMain — modelos del dominio
// No tienen anotaciones de Room ni de Retrofit — son Kotlin puro
data class Producto(
val id: Int,
val nombre: String,
val precio: Double,
val descripcion: String?,
val imageUrl: String
)
data class Carrito(
val items: List<ItemCarrito>
) {
val total: Double get() = items.sumOf { it.subtotal }
val cantidadItems: Int get() = items.sumOf { it.cantidad }
}
data class ItemCarrito(
val producto: Producto,
val cantidad: Int
) {
val subtotal: Double get() = producto.precio * cantidad
}
// Resultado tipado para operaciones que pueden fallar
sealed class Resultado<out T> {
data class Exito<T>(val datos: T) : Resultado<T>()
data class Error(val mensaje: String, val excepcion: Throwable? = null) : Resultado<Nothing>()
object Cargando : Resultado<Nothing>()
}
// DTO de red — separado del modelo de dominio
// En data/remote/dto/
@Serializable
data class ProductoDto(
@SerialName("id") val id: Int,
@SerialName("name") val nombre: String,
@SerialName("price") val precio: Double,
@SerialName("description") val descripcion: String? = null,
@SerialName("image_url") val imageUrl: String
)
// Mapper DTO → Dominio
fun ProductoDto.toDomain() = Producto(
id = id,
nombre = nombre,
precio = precio,
descripcion = descripcion,
imageUrl = imageUrl
)
Repositorios — interfaz en common, implementación en data
// domain/repository/ProductoRepository.kt — commonMain
interface ProductoRepository {
fun getProductos(): Flow<List<Producto>>
suspend fun getProducto(id: Int): Producto?
suspend fun buscar(query: String): List<Producto>
suspend fun sincronizar(): Resultado<Unit>
}
// data/repository/ProductoRepositoryImpl.kt — commonMain
class ProductoRepositoryImpl(
private val api: ProductoApi,
private val localDataSource: ProductoLocalDataSource
) : ProductoRepository {
override fun getProductos(): Flow<List<Producto>> {
// Primero emite del caché local, luego sincroniza con la API
return localDataSource.observarProductos()
}
override suspend fun getProducto(id: Int): Producto? {
return localDataSource.getProducto(id)
?: api.getProducto(id)?.toDomain()?.also {
localDataSource.guardar(it)
}
}
override suspend fun buscar(query: String): List<Producto> {
return api.buscar(query).map { it.toDomain() }
}
override suspend fun sincronizar(): Resultado<Unit> {
return try {
val productos = api.getProductos().map { it.toDomain() }
localDataSource.reemplazarTodos(productos)
Resultado.Exito(Unit)
} catch (e: Exception) {
Resultado.Error("Error al sincronizar: ${e.message}", e)
}
}
}
Use cases compartidos
// domain/usecase/GetProductosUseCase.kt — commonMain
// Kotlin puro, sin dependencias de Android ni iOS
class GetProductosUseCase(
private val repository: ProductoRepository
) {
operator fun invoke(): Flow<List<Producto>> {
return repository.getProductos()
}
}
class BuscarProductosUseCase(
private val repository: ProductoRepository
) {
suspend operator fun invoke(query: String): Resultado<List<Producto>> {
if (query.length < 2) return Resultado.Error("El query debe tener al menos 2 caracteres")
return try {
Resultado.Exito(repository.buscar(query))
} catch (e: Exception) {
Resultado.Error(e.message ?: "Error desconocido", e)
}
}
}
// Los use cases del shared module son testeables sin emulador:
// commonTest/kotlin/ar/pensa/app/shared/domain/usecase/
class BuscarProductosUseCaseTest {
@Test
fun `query corto retorna error`() = runTest {
val fakeRepo = FakeProductoRepository()
val useCase = BuscarProductosUseCase(fakeRepo)
val resultado = useCase("a") // query de 1 caracter
assertTrue(resultado is Resultado.Error)
}
}
expect/actual en casos reales
// Caso 1: Logging
// commonMain:
expect fun log(tag: String, mensaje: String)
// androidMain:
actual fun log(tag: String, mensaje: String) {
android.util.Log.d(tag, mensaje)
}
// iosMain:
actual fun log(tag: String, mensaje: String) {
println("[$tag] $mensaje")
}
// Caso 2: Configuración de red por plataforma
// commonMain:
expect fun crearHttpClientEngine(): io.ktor.client.engine.HttpClientEngine
// androidMain:
actual fun crearHttpClientEngine() = OkHttp.create()
// iosMain:
actual fun crearHttpClientEngine() = Darwin.create()
// Caso 3: Almacenamiento de settings simples
// commonMain:
expect class Settings(name: String) {
fun getString(key: String, default: String): String
fun putString(key: String, value: String)
fun remove(key: String)
}
// androidMain — usando SharedPreferences:
actual class Settings actual constructor(private val name: String) {
private val prefs by lazy {
// Necesitamos el Context de Android — ver cómo pasarlo en la lección de DI
appContext.getSharedPreferences(name, android.content.Context.MODE_PRIVATE)
}
actual fun getString(key: String, default: String) =
prefs.getString(key, default) ?: default
actual fun putString(key: String, value: String) =
prefs.edit().putString(key, value).apply()
actual fun remove(key: String) =
prefs.edit().remove(key).apply()
}
// iosMain — usando NSUserDefaults:
actual class Settings actual constructor(name: String) {
private val defaults = platform.Foundation.NSUserDefaults(suiteName = name)
actual fun getString(key: String, default: String) =
defaults?.stringForKey(key) ?: default
actual fun putString(key: String, value: String) =
defaults?.setObject(value, key)
actual fun remove(key: String) =
defaults?.removeObjectForKey(key)
}
Fechas con kotlinx-datetime
java.util.Date y java.time.* no están disponibles en iOS. La librería oficial para fechas en KMP es kotlinx-datetime:
// Agregar en commonMain.dependencies:
// implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
import kotlinx.datetime.*
// Instante en el tiempo (equivalente a Instant de java.time)
val ahora: Instant = Clock.System.now()
// Fecha local (sin timezone)
val hoy: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault())
// Formatear para mostrar
val formato = LocalDate(2025, 4, 17)
val texto = formato.toString() // "2025-04-17"
// Comparar fechas
val ayer = hoy.minus(1, DateTimeUnit.DAY)
val esFuturo = hoy > ayer // true
// Convertir a/desde timestamp (para persistir en base de datos)
val timestamp: Long = ahora.toEpochMilliseconds()
val deTimestamp: Instant = Instant.fromEpochMilliseconds(timestamp)
// Calcular diferencia
val diferencia = ahora - Instant.fromEpochMilliseconds(otroTimestamp)
val diasDiferencia = diferencia.inWholeDays