Visión General

El módulo de Catálogo de Servicios permite a los usuarios descubrir y gestionar servicios profesionales. Implementa una arquitectura limpia con ViewModels, Jetpack Compose y Retrofit para una experiencia fluida.

  • Explorar categorías y subcategorías jerárquicas
  • Buscar servicios por palabra clave, ubicación y categoría
  • Ver detalles completos de listados de servicios
  • Guardar y gestionar favoritos
  • Contactar directamente a proveedores de servicios

Servicio API de Catálogo

Interfaz Retrofit

API Interface
network/CatalogApiService.kt
package com.imirly.app.network

import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.*

data class CategoriaResponse(
    val id: String,
    val nombre: String,
    val icono: String,
    val numAnuncios: Int? = 0
)

data class SubcategoriaResponse(
    val id: String,
    val categoriaId: String,
    val nombre: String,
    val icono: String
)

data class AnuncioResponse(
    val id: String,
    val titulo: String,
    val descripcion: String,
    val precioHora: Double,
    val ubicacion: String,
    val activo: Boolean,
    val subcategoriaId: String,
    val nombreProfesional: String? = "Profesional",
    val valoracionProfesional: Double = 0.0,
    val numeroValoracionesProfesional: Int = 0
)

data class CheckFavoritoResponse(
    val isFavorito: Boolean
)

interface CatalogApiService {
    @GET("/api/v1/catalog/categories")
    suspend fun getCategorias(): Response>

    @GET("/api/v1/catalog/categories/{categoriaId}/subcategories")
    suspend fun getSubcategorias(
        @Path("categoriaId") categoriaId: String
    ): Response>

    @GET("/api/v1/anuncios/subcategoria/{subcategoriaId}")
    suspend fun getAnunciosBySubcategoria(
        @Path("subcategoriaId") subcategoriaId: String
    ): Response>

    @GET("/api/v1/anuncios/search")
    suspend fun searchAnuncios(
        @Query("q") query: String? = null,
        @Query("ubicacion") ubicacion: String? = null,
        @Query("categoriaId") categoriaId: String? = null
    ): Response>

    @GET("/api/v1/anuncios/{id}")
    suspend fun getAnuncioById(@Path("id") id: String): Response

    @POST("/api/v1/favoritos/{anuncioId}")
    suspend fun addFavorito(@Path("anuncioId") anuncioId: String): Response

    @DELETE("/api/v1/favoritos/{anuncioId}")
    suspend fun removeFavorito(@Path("anuncioId") anuncioId: String): Response

    @GET("/api/v1/favoritos")
    suspend fun getMisFavoritos(): Response>

    @GET("/api/v1/favoritos/{anuncioId}/check")
    suspend fun checkIsFavorito(
        @Path("anuncioId") anuncioId: String
    ): Response
}

Nota: Todos los endpoints del catálogo requieren autenticación JWT. El RetrofitClient inyecta automáticamente el token en el header Authorization.

Pantalla de Inicio con Categorías

Home ViewModel

ViewModel
ui/main/HomeViewModel.kt
package com.imirly.app.ui.main

import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.imirly.app.network.CategoriaResponse
import com.imirly.app.network.RetrofitClient
import kotlinx.coroutines.launch

class HomeViewModel : ViewModel() {
    var categorias = mutableStateOf>(emptyList())
    var isLoading = mutableStateOf(true)

    init {
        cargarCategorias()
    }

    private fun cargarCategorias() {
        viewModelScope.launch {
            try {
                val response = RetrofitClient.catalogService.getCategorias()
                if (response.isSuccessful) {
                    categorias.value = response.body() ?: emptyList()
                }
            } catch (e: Exception) {
                // Manejar error
            } finally {
                isLoading.value = false
            }
        }
    }
}

Home Screen UI

Jetpack Compose
ui/main/HomeScreen.kt
package com.imirly.app.ui.main

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController

@Composable
fun HomeScreen(navController: NavController, viewModel: HomeViewModel = viewModel()) {
    val iMirlyPurple = Color(0xFF6C5CE7)
    val lightBackground = Color(0xFFFAFAFA)

    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(lightBackground)
            .verticalScroll(rememberScrollState())
            .padding(24.dp)
    ) {
        // Encabezado
        Text(
            "iMirly",
            fontSize = 28.sp,
            fontWeight = FontWeight.ExtraBold,
            color = iMirlyPurple
        )
        Text("Encuentra tu profesional ideal", fontSize = 14.sp, color = Color.Gray)

        Spacer(modifier = Modifier.height(20.dp))

        // Barra de Búsqueda (clicable)
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .clickable { navController.navigate("search") }
        ) {
            OutlinedTextField(
                value = "",
                onValueChange = {},
                placeholder = { Text("Busca servicios...", color = Color.Gray) },
                leadingIcon = {
                    Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray)
                },
                modifier = Modifier.fillMaxWidth(),
                shape = RoundedCornerShape(16.dp),
                enabled = false
            )
        }

        Spacer(modifier = Modifier.height(24.dp))

        // Banner Promocional
        Surface(
            modifier = Modifier.fillMaxWidth(),
            shape = RoundedCornerShape(24.dp),
            color = iMirlyPurple
        ) {
            Column(modifier = Modifier.padding(24.dp)) {
                Surface(
                    color = Color.White.copy(alpha = 0.2f),
                    shape = RoundedCornerShape(8.dp)
                ) {
                    Text(
                        "✨ NUEVO",
                        color = Color.White,
                        fontSize = 10.sp,
                        fontWeight = FontWeight.Bold,
                        modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
                    )
                }
                Spacer(modifier = Modifier.height(12.dp))
                Text(
                    "Servicios locales,\nsoluciones reales.\niMirly",
                    color = Color.White,
                    fontSize = 22.sp,
                    fontWeight = FontWeight.Bold,
                    lineHeight = 28.sp
                )
            }
        }

        Spacer(modifier = Modifier.height(32.dp))

        // Sección de Categorías
        Text("Categorías populares", fontSize = 18.sp, fontWeight = FontWeight.Bold)

        Spacer(modifier = Modifier.height(16.dp))

        if (viewModel.isLoading.value) {
            CircularProgressIndicator(
                modifier = Modifier.align(Alignment.CenterHorizontally),
                color = iMirlyPurple
            )
        } else {
            // Grid de 2 columnas
            val categorias = viewModel.categorias.value
            for (i in categorias.indices step 2) {
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.spacedBy(16.dp)
                ) {
                    CategoryCard(
                        categoria = categorias[i],
                        modifier = Modifier
                            .weight(1f)
                            .clickable {
                                navController.navigate(
                                    "subcategories/${categorias[i].id}/${categorias[i].nombre}"
                                )
                            }
                    )

                    if (i + 1 < categorias.size) {
                        CategoryCard(
                            categoria = categorias[i + 1],
                            modifier = Modifier
                                .weight(1f)
                                .clickable {
                                    navController.navigate(
                                        "subcategories/${categorias[i + 1].id}/${categorias[i + 1].nombre}"
                                    )
                                }
                        )
                    } else {
                        Spacer(modifier = Modifier.weight(1f))
                    }
                }
                Spacer(modifier = Modifier.height(16.dp))
            }
        }
    }
}

@Composable
fun CategoryCard(categoria: CategoriaResponse, modifier: Modifier = Modifier) {
    Surface(
        modifier = modifier.height(180.dp),
        shape = RoundedCornerShape(20.dp),
        color = Color.White,
        shadowElevation = 2.dp
    ) {
        Column(
            modifier = Modifier.padding(8.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            // Icono de categoría (cargar desde recursos drawable)
            // ...
            
            Text(
                categoria.nombre,
                fontSize = 14.sp,
                fontWeight = FontWeight.Bold,
                color = Color(0xFF6C5CE7)
            )
            Text(
                "${categoria.numAnuncios ?: 0} profesionales",
                fontSize = 10.sp,
                color = Color.Gray
            )
        }
    }
}

Vista de Detalle del Servicio

Detail ViewModel

ViewModel
ui/catalog/AnuncioDetailViewModel.kt
package com.imirly.app.ui.catalog

import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.imirly.app.network.AnuncioResponse
import com.imirly.app.network.CrearSolicitudRequest
import com.imirly.app.network.RetrofitClient
import kotlinx.coroutines.launch

class AnuncioDetailViewModel : ViewModel() {
    var anuncio = mutableStateOf(null)
    var isLoading = mutableStateOf(true)
    var showContactDialog = mutableStateOf(false)
    var mensajeContacto = mutableStateOf("")
    var isSubmitting = mutableStateOf(false)
    var requestSuccess = mutableStateOf(false)

    fun cargarDetalle(id: String) {
        isLoading.value = true
        viewModelScope.launch {
            try {
                val response = RetrofitClient.catalogService.getAnuncioById(id)
                if (response.isSuccessful) {
                    anuncio.value = response.body()
                }
            } finally {
                isLoading.value = false
            }
        }
    }

    fun enviarSolicitud(anuncioId: String) {
        if (mensajeContacto.value.isBlank()) return

        isSubmitting.value = true
        viewModelScope.launch {
            try {
                val request = CrearSolicitudRequest(anuncioId, mensajeContacto.value)
                val response = RetrofitClient.solicitudService.crearSolicitud(request)

                if (response.isSuccessful) {
                    requestSuccess.value = true
                }
            } catch (e: Exception) {
                e.printStackTrace()
            } finally {
                isSubmitting.value = false
            }
        }
    }
}

Detail Screen UI

Jetpack Compose
ui/catalog/AnuncioDetailScreen.kt
@Composable
fun AnuncioDetailScreen(
    navController: NavController,
    anuncioId: String,
    viewModel: AnuncioDetailViewModel = viewModel()
) {
    LaunchedEffect(anuncioId) {
        viewModel.cargarDetalle(anuncioId)
    }

    val anuncio = viewModel.anuncio.value

    if (viewModel.isLoading.value) {
        Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            CircularProgressIndicator()
        }
    } else if (anuncio != null) {
        Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
            Text(anuncio.titulo, fontSize = 24.sp, fontWeight = FontWeight.Bold)
            Text("${anuncio.precioHora}€/hora", fontSize = 20.sp, color = Color(0xFF6C5CE7))
            
            Spacer(modifier = Modifier.height(16.dp))
            
            Text(anuncio.descripcion, fontSize = 16.sp)
            
            Spacer(modifier = Modifier.height(8.dp))
            
            Row(verticalAlignment = Alignment.CenterVertically) {
                Icon(Icons.Default.LocationOn, contentDescription = null)
                Text(anuncio.ubicacion)
            }
            
            Spacer(modifier = Modifier.height(16.dp))
            
            // Información del profesional
            Text("Profesional: ${anuncio.nombreProfesional}")
            Text("⭐ ${anuncio.valoracionProfesional} (${anuncio.numeroValoracionesProfesional} valoraciones)")
            
            Spacer(modifier = Modifier.weight(1f))
            
            Button(
                onClick = { viewModel.showContactDialog.value = true },
                modifier = Modifier.fillMaxWidth()
            ) {
                Text("Contactar profesional")
            }
        }
    }
}

Sistema de Favoritos

Añadir/Eliminar Favoritos

Favoritos - Toggle
fun toggleFavorito(anuncioId: String, isFavorito: Boolean) {
    viewModelScope.launch {
        try {
            val response = if (isFavorito) {
                RetrofitClient.catalogService.removeFavorito(anuncioId)
            } else {
                RetrofitClient.catalogService.addFavorito(anuncioId)
            }
            
            if (response.isSuccessful) {
                // Actualizar interfaz
            }
        } catch (e: Exception) {
            // Manejar error
        }
    }
}

Verificar Estado de Favorito

Favoritos - Check
fun checkFavorito(anuncioId: String) {
    viewModelScope.launch {
        try {
            val response = RetrofitClient.catalogService.checkIsFavorito(anuncioId)
            if (response.isSuccessful) {
                val isFav = response.body()?.isFavorito ?: false
                // Actualizar interfaz
            }
        } catch (e: Exception) {
            // Manejar error
        }
    }
}

Probar el Catálogo

  1. Cargar Categorías

    Abre la pantalla de inicio y verifica que las categorías se carguen desde la API correctamente.

  2. Navegar a Subcategorías

    Toca una categoría y verifica que las subcategorías se muestren correctamente con sus iconos.

  3. Buscar Servicios

    Usa la barra de búsqueda para encontrar servicios por palabra clave o ubicación. Verifica los filtros.

  4. Ver Detalles

    Toca un listado de servicio y verifica que todos los detalles se muestren: precio, ubicación, valoraciones.

  5. Probar Favoritos

    Añade/elimina favoritos y verifica la persistencia. Comprueba que el estado se mantiene al recargar.

Próximos Pasos