Catálogo de Servicios
Explora, busca y gestiona listados de servicios en la aplicación iMirly Android
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 Interfacepackage 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
ViewModelpackage 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 Composepackage 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
)
}
}
}
Funcionalidad de Búsqueda
Search ViewModel
ViewModelpackage 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.RetrofitClient
import kotlinx.coroutines.launch
class SearchViewModel : ViewModel() {
var searchQuery = mutableStateOf("")
var ubicacion = mutableStateOf("")
var resultados = mutableStateOf>(emptyList())
var isLoading = mutableStateOf(false)
fun buscarAnuncios() {
isLoading.value = true
viewModelScope.launch {
try {
val response = RetrofitClient.catalogService.searchAnuncios(
query = searchQuery.value.ifBlank { null },
ubicacion = ubicacion.value.ifBlank { null }
)
if (response.isSuccessful) {
resultados.value = response.body() ?: emptyList()
}
} catch (e: Exception) {
// Manejar error
} finally {
isLoading.value = false
}
}
}
}
Vista de Detalle del Servicio
Detail ViewModel
ViewModelpackage 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@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
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
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
-
Cargar Categorías
Abre la pantalla de inicio y verifica que las categorías se carguen desde la API correctamente.
-
Navegar a Subcategorías
Toca una categoría y verifica que las subcategorías se muestren correctamente con sus iconos.
-
Buscar Servicios
Usa la barra de búsqueda para encontrar servicios por palabra clave o ubicación. Verifica los filtros.
-
Ver Detalles
Toca un listado de servicio y verifica que todos los detalles se muestren: precio, ubicación, valoraciones.
-
Probar Favoritos
Añade/elimina favoritos y verifica la persistencia. Comprueba que el estado se mantiene al recargar.