Arquitectura por capas para app de micro-cursos de IA · Android + KMP · Koin · Room · Ktor
android.* en domain → arquitectura rota.
// ✅ Pure Kotlin — zero framework imports package ia.cultiva.domain.model data class Curso( val id: String, val titulo: String, val descripcion: String, val categoria: Categoria, val dificultad: Dificultad, val duracionMin: Int, val lecciones: List<Leccion> = emptyList() ) data class Leccion( val id: String, val cursoId: String, val titulo: String, val ordenIndex: Int, val completada: Boolean = false ) data class Progreso( val userId: String, val cursoId: String, val leccionesCompletadas: Int, val total: Int ) { val porcentaje: Float get() = if (total == 0) 0f else leccionesCompletadas.toFloat() / total val completado: Boolean get() = leccionesCompletadas >= total } enum class Categoria { MARKETING, AUTOMATIZACION, DATOS, AGENTES } enum class Dificultad { BASICO, INTERMEDIO, AVANZADO } // ───────────────────────────────────────────────── // domain/usecase/GetCursosUseCase.kt class GetCursosUseCase( private val repository: CursoRepository ) { suspend operator fun invoke(categoria: Categoria? = null): Result<List<Curso>> = repository.getCursos(categoria) } class MarcarLeccionCompletadaUseCase( private val repository: CursoRepository ) { suspend operator fun invoke(leccionId: String, userId: String): Result<Progreso> = repository.marcarLeccionCompletada(leccionId, userId) } class ObservarProgresoUseCase( private val repository: CursoRepository ) { operator fun invoke(userId: String): Flow<List<Progreso>> = repository.observarProgreso(userId) } // ───────────────────────────────────────────────── // domain/repository/CursoRepository.kt interface CursoRepository { suspend fun getCursos(categoria: Categoria? = null): Result<List<Curso>> suspend fun getCursoPorId(id: String): Result<Curso> suspend fun marcarLeccionCompletada(leccionId: String, userId: String): Result<Progreso> fun observarProgreso(userId: String): Flow<List<Progreso>> }
class CursoRepositoryImpl( private val localDs: CursoLocalDataSource, private val remoteDs: CursoRemoteDataSource ) : CursoRepository { override suspend fun getCursos(categoria: Categoria?): Result<List<Curso>> = runCatching { // 1. Fetch remoto y cachear en Room val dtos = remoteDs.fetchCursos(categoria?.name) localDs.upsertCursos(dtos.map { it.toEntity() }) // 2. Leer de local (fuente de verdad) localDs.getCursos(categoria?.name).map { it.toDomain() } } override suspend fun getCursoPorId(id: String): Result<Curso> = runCatching { localDs.getCursoPorId(id).toDomain() } override suspend fun marcarLeccionCompletada( leccionId: String, userId: String ): Result<Progreso> = runCatching { localDs.marcarCompletada(leccionId) val progreso = localDs.getProgreso(userId, /*cursoId derivado*/ leccionId.substringBefore("-")) progreso.toDomain() } override fun observarProgreso(userId: String): Flow<List<Progreso>> = localDs.observarProgresoFlow(userId).map { list -> list.map { it.toDomain() } } } // ─── Room Entities ───────────────────────────────── @Entity(tableName = "cursos") data class CursoEntity( @PrimaryKey val id: String, val titulo: String, val descripcion: String, val categoria: String, val dificultad: String, val duracionMin: Int ) @Entity(tableName = "lecciones") data class LeccionEntity( @PrimaryKey val id: String, val cursoId: String, val titulo: String, val ordenIndex: Int, val completada: Boolean = false ) @Dao interface CursoDao { @Query("SELECT * FROM cursos WHERE (:cat IS NULL OR categoria = :cat)") suspend fun getCursos(cat: String?): List<CursoEntity> @Upsert suspend fun upsertCursos(cursos: List<CursoEntity>) @Query("UPDATE lecciones SET completada = 1 WHERE id = :id") suspend fun marcarCompletada(id: String) @Query("SELECT * FROM lecciones WHERE cursoId = :cursoId") fun observarLecciones(cursoId: String): Flow<List<LeccionEntity>> } // ─── Mappers (en data layer, nunca en domain) ────── fun CursoEntity.toDomain() = Curso( id = id, titulo = titulo, descripcion = descripcion, categoria = Categoria.valueOf(categoria), dificultad = Dificultad.valueOf(dificultad), duracionMin = duracionMin ) fun CursoDto.toEntity() = CursoEntity(id, titulo, descripcion, categoria, dificultad, duracionMin) // ─── Ktor Remote DataSource ──────────────────────── class CursoRemoteDataSource(private val client: HttpClient) { suspend fun fetchCursos(categoria: String?): List<CursoDto> = client.get("v1/cursos") { categoria?.let { parameter("categoria", it) } }.body() } val httpClient = HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } install(Logging) { level = LogLevel.HEADERS } defaultRequest { url("https://api.cultivaaprende.com/") } }
@HiltViewModel class CursosViewModel @Inject constructor( private val getCursos: GetCursosUseCase, private val observarProgreso: ObservarProgresoUseCase, private val marcarLeccion: MarcarLeccionCompletadaUseCase ) : ViewModel() { private val _state = MutableStateFlow(CursosUiState()) val state = _state.asStateFlow() fun cargarCursos(categoria: Categoria? = null) { viewModelScope.launch { _state.update { it.copy(cargando = true) } getCursos(categoria) .onSuccess { cursos -> _state.update { it.copy(cursos = cursos, cargando = false) } } .onFailure { err -> _state.update { it.copy(error = err.message, cargando = false) } } } } fun marcarCompletada(leccionId: String) { viewModelScope.launch { marcarLeccion(leccionId, "user-001") .onSuccess { progreso -> _state.update { it.copy(progreso = progreso) } } } } } data class CursosUiState( val cursos: List<Curso> = emptyList(), val progreso: Progreso? = null, val cargando: Boolean = false, val error: String? = null )
// domain/src/.../di/domainModule.kt val domainModule = module { factory { GetCursosUseCase(get()) } factory { MarcarLeccionCompletadaUseCase(get()) } factory { ObservarProgresoUseCase(get()) } } // data/src/.../di/dataModule.kt val dataModule = module { single<CursoRepository> { CursoRepositoryImpl(get(), get()) } single { CursoLocalDataSource(get()) } single { CursoRemoteDataSource(get()) } single { httpClient } single { Room.databaseBuilder( get(), CultivaDatabase::class.java, "cultiva.db" ).build() } } // presentation/src/.../di/presentationModule.kt val presentationModule = module { viewModelOf(::CursosViewModel) viewModelOf(::DashboardViewModel) } // app/src/.../CultivaAprende.kt class CultivaAprende : Application() { override fun onCreate() { super.onCreate() startKoin { androidContext(this@CultivaAprende) modules(domainModule, dataModule, presentationModule) } } }
import android.* en domain. Si necesitas Context, pásalo desde data/app vía interfaz abstracta.viewModelScope en ViewModels y coroutineScope estructurado en tests. GlobalScope causa leaks.