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