Visión General

El sistema de contratos y pagos de iMirly maneja el ciclo de vida completo de las solicitudes de servicio:

  • El cliente crea la solicitud (Solicitud)
  • El profesional envía propuesta de precio
  • El cliente acepta y paga (fondos retenidos en escrow)
  • El profesional completa el trabajo
  • El cliente aprueba y libera el pago
  • El cliente valora al profesional

Servicio API de Solicitudes

Interfaz Retrofit

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

import retrofit2.Response
import retrofit2.http.*

data class CrearSolicitudRequest(
    val anuncioId: String,
    val detalles: String
)

data class SolicitudResponse(
    val id: String,
    val clienteId: String,
    val anuncioId: String,
    val estado: String,
    val detallesSolicitud: String,
    val fechaCreacion: String,
    val nombreCliente: String? = "Cliente",
    val proveedorId: String? = null,
    val nombreProveedor: String? = "Profesional",
    var soyElProveedor: Boolean = false
)

data class CambiarEstadoRequest(
    val nuevoEstado: String
)

interface SolicitudApiService {
    @POST("/api/v1/solicitudes")
    suspend fun crearSolicitud(
        @Body request: CrearSolicitudRequest
    ): Response

    @GET("/api/v1/solicitudes/proveedor")
    suspend fun getSolicitudesProveedor(): Response>

    @GET("/api/v1/solicitudes/cliente")
    suspend fun getSolicitudesCliente(): Response>

    @PATCH("/api/v1/solicitudes/{id}/estado")
    suspend fun cambiarEstadoSolicitud(
        @Path("id") id: String,
        @Body request: CambiarEstadoRequest
    ): Response

    @DELETE("/api/v1/solicitudes/{id}")
    suspend fun eliminarSolicitud(@Path("id") id: String): Response

    @POST("/api/v1/solicitudes/{id}/pagar")
    suspend fun pagarSolicitud(@Path("id") id: String): Response
}

Crear Solicitudes de Servicio

Desde Pantalla de Detalle

Cuando un usuario ve un anuncio de servicio, puede contactar al profesional:

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.CrearSolicitudRequest
import com.imirly.app.network.RetrofitClient
import kotlinx.coroutines.launch

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

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

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

                if (response.isSuccessful) {
                    requestSuccess.value = true
                } else {
                    println("Error del servidor: ${response.code()}")
                }
            } catch (e: Exception) {
                println("Fallo crítico en Retrofit: ${e.message}")
                e.printStackTrace()
            } finally {
                isSubmitting.value = false
            }
        }
    }
}

Diálogo de Contacto

Jetpack Compose
ContactDialog.kt
@Composable
fun ContactDialog(
    onDismiss: () -> Unit,
    onConfirm: () -> Unit,
    mensaje: MutableState,
    isSubmitting: Boolean
) {
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("Contactar profesional") },
        text = {
            Column {
                Text("Describe tu solicitud:")
                Spacer(modifier = Modifier.height(8.dp))
                OutlinedTextField(
                    value = mensaje.value,
                    onValueChange = { mensaje.value = it },
                    placeholder = { Text("Ej: Necesito reparar una tubería...") },
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(120.dp),
                    maxLines = 5
                )
            }
        },
        confirmButton = {
            Button(
                onClick = onConfirm,
                enabled = !isSubmitting && mensaje.value.isNotBlank()
            ) {
                Text(if (isSubmitting) "Enviando..." else "Enviar")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("Cancelar")
            }
        }
    )
}

Estados del Ciclo de Vida

La solicitud pasa por varios estados:

1

PENDIENTE

Estado inicial cuando el cliente crea la solicitud

2

ACEPTADO

El profesional ha revisado y enviado propuesta de precio

3

PAGADO

El cliente aceptó la propuesta y pagó (fondos en escrow)

4

COMPLETADO

El profesional marcó el trabajo como completado

5

FINALIZADO

El cliente aprobó el trabajo y liberó el pago

Pantalla de Mensajes (Bandeja de Entrada)

Messages ViewModel

ViewModel
ui/messages/MessagesViewModel.kt
package com.imirly.app.ui.messages

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

class MessagesViewModel : ViewModel() {
    var solicitudes = mutableStateOf>(emptyList())
    var isLoading = mutableStateOf(true)

    fun cargarSolicitudes() {
        isLoading.value = true
        viewModelScope.launch {
            try {
                // Cargar solicitudes como cliente y como proveedor
                val respCliente = RetrofitClient.solicitudService.getSolicitudesCliente()
                val respProveedor = RetrofitClient.solicitudService.getSolicitudesProveedor()

                val listCliente = if (respCliente.isSuccessful) {
                    respCliente.body() ?: emptyList()
                } else emptyList()

                val listProveedor = if (respProveedor.isSuccessful) {
                    respProveedor.body()?.map { it.copy(soyElProveedor = true) } ?: emptyList()
                } else emptyList()

                solicitudes.value = listCliente + listProveedor
            } catch (e: Exception) {
                // Manejar error
            } finally {
                isLoading.value = false
            }
        }
    }
}

Messages Screen UI

Jetpack Compose
ui/messages/MessagesScreen.kt
package com.imirly.app.ui.messages

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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
import com.imirly.app.network.SolicitudResponse

@Composable
fun MessagesScreen(navController: NavController, viewModel: MessagesViewModel = viewModel()) {
    LaunchedEffect(Unit) {
        viewModel.cargarSolicitudes()
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Text(
            "Mensajes",
            fontSize = 24.sp,
            fontWeight = FontWeight.Bold,
            color = Color(0xFF6C5CE7)
        )
        Spacer(modifier = Modifier.height(16.dp))

        if (viewModel.isLoading.value) {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
        } else if (viewModel.solicitudes.value.isEmpty()) {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                Text("No tienes conversaciones activas", color = Color.Gray)
            }
        } else {
            LazyColumn {
                items(viewModel.solicitudes.value) { solicitud ->
                    ConversationCard(
                        solicitud = solicitud,
                        onClick = {
                            val otroUsuarioId = if (solicitud.soyElProveedor) {
                                solicitud.clienteId
                            } else {
                                solicitud.proveedorId ?: ""
                            }
                            val nombreOtroUsuario = if (solicitud.soyElProveedor) {
                                solicitud.nombreCliente ?: "Cliente"
                            } else {
                                solicitud.nombreProveedor ?: "Profesional"
                            }
                            navController.navigate(
                                "chat/$otroUsuarioId/$nombreOtroUsuario/${solicitud.id}"
                            )
                        }
                    )
                    Spacer(modifier = Modifier.height(12.dp))
                }
            }
        }
    }
}

@Composable
fun ConversationCard(solicitud: SolicitudResponse, onClick: () -> Unit) {
    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .clickable(onClick = onClick),
        shape = RoundedCornerShape(12.dp),
        color = Color.White,
        shadowElevation = 2.dp
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            // Avatar placeholder
            Surface(
                modifier = Modifier.size(48.dp),
                shape = RoundedCornerShape(24.dp),
                color = Color(0xFF6C5CE7)
            ) {
                Box(contentAlignment = Alignment.Center) {
                    Text(
                        if (solicitud.soyElProveedor) 
                            solicitud.nombreCliente?.first()?.toString() ?: "C"
                        else 
                            solicitud.nombreProveedor?.first()?.toString() ?: "P",
                        color = Color.White,
                        fontWeight = FontWeight.Bold,
                        fontSize = 20.sp
                    )
                }
            }

            Spacer(modifier = Modifier.width(12.dp))

            Column(modifier = Modifier.weight(1f)) {
                Text(
                    if (solicitud.soyElProveedor) 
                        solicitud.nombreCliente ?: "Cliente"
                    else 
                        solicitud.nombreProveedor ?: "Profesional",
                    fontWeight = FontWeight.Bold,
                    fontSize = 16.sp
                )
                Text(
                    solicitud.detallesSolicitud,
                    color = Color.Gray,
                    fontSize = 14.sp,
                    maxLines = 1
                )
            }

            // Status badge
            Surface(
                shape = RoundedCornerShape(8.dp),
                color = when (solicitud.estado) {
                    "PENDIENTE" -> Color(0xFFFFB74D)
                    "ACEPTADO" -> Color(0xFF64B5F6)
                    "PAGADO" -> Color(0xFF81C784)
                    "COMPLETADO" -> Color(0xFF9575CD)
                    "FINALIZADO" -> Color(0xFF4CAF50)
                    else -> Color.Gray
                }
            ) {
                Text(
                    solicitud.estado,
                    color = Color.White,
                    fontSize = 10.sp,
                    fontWeight = FontWeight.Bold,
                    modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
                )
            }
        }
    }
}

Procesamiento de Pagos

Pago en Escrow

Cuando un cliente acepta una propuesta, los fondos se retienen en escrow:

Pago en Escrow
fun aceptarYPagar(solicitudId: String, otroUsuarioId: String) {
    viewModelScope.launch {
        try {
            val responsePago = RetrofitClient.solicitudService.pagarSolicitud(solicitudId)

            if (responsePago.isSuccessful) {
                // Enviar mensaje de notificación
                val requestMsg = EnviarMensajeRequest(
                    receptorId = otroUsuarioId,
                    contenido = "He aceptado el presupuesto y he realizado el pago. El dinero está retenido de forma segura en iMirly. Ya puedes empezar el trabajo.",
                    solicitudId = solicitudId,
                    tipo = "PRESUPUESTO_ACEPTADO"
                )
                RetrofitClient.mensajeService.enviarMensaje(requestMsg)
                
                // Recargar conversación
                cargarConversacion(otroUsuarioId)
            } else {
                // Manejar error de pago
                when (responsePago.code()) {
                    400 -> println("Saldo insuficiente")
                    404 -> println("Solicitud no encontrada")
                    else -> println("Error de pago: ${responsePago.code()}")
                }
            }
        } catch (e: Exception) {
            android.util.Log.e("ChatViewModel", "Excepción crítica al pagar", e)
        }
    }
}

El endpoint de pago verifica el saldo del usuario antes de procesar. Asegúrate de que los usuarios tengan fondos suficientes cargados.

Flujo de Completar Trabajo

Profesional Marca como Completado

Marcar Trabajo Finalizado
fun marcarTrabajoFinalizado(receptorId: String, solicitudId: String) {
    viewModelScope.launch {
        try {
            val requestMsg = EnviarMensajeRequest(
                receptorId = receptorId,
                contenido = "He marcado el trabajo como finalizado. Por favor, revísalo y confirma si todo está correcto para liberar el pago.",
                solicitudId = solicitudId,
                tipo = "TRABAJO_FINALIZADO"
            )
            val msgResponse = RetrofitClient.mensajeService.enviarMensaje(requestMsg)
            if (msgResponse.isSuccessful) cargarConversacion(receptorId)
        } catch (e: Exception) {
            android.util.Log.e("ChatViewModel", "Error finalizando", e)
        }
    }
}

Cliente Aprueba y Libera Pago

Aprobar Trabajo
fun aprobarTrabajo(receptorId: String, solicitudId: String) {
    viewModelScope.launch {
        try {
            val requestMsg = EnviarMensajeRequest(
                receptorId = receptorId,
                contenido = "He revisado el trabajo y está perfecto. Pago liberado, ¡muchas gracias!",
                solicitudId = solicitudId,
                tipo = "SERVICIO_COMPLETADO"
            )
            val msgResponse = RetrofitClient.mensajeService.enviarMensaje(requestMsg)
            if (msgResponse.isSuccessful) {
                cargarConversacion(receptorId)
                // El pago se libera al profesional
            }
        } catch (e: Exception) {
            android.util.Log.e("ChatViewModel", "Error aprobando", e)
        }
    }
}

Cliente Rechaza Trabajo

Rechazar Trabajo
fun rechazarTrabajo(receptorId: String, solicitudId: String) {
    viewModelScope.launch {
        try {
            val requestMsg = EnviarMensajeRequest(
                receptorId = receptorId,
                contenido = "El trabajo no cumple con lo acordado. Necesita arreglos.",
                solicitudId = solicitudId,
                tipo = "SERVICIO_RECHAZADO"
            )
            val msgResponse = RetrofitClient.mensajeService.enviarMensaje(requestMsg)
            if (msgResponse.isSuccessful) cargarConversacion(receptorId)
        } catch (e: Exception) {
            android.util.Log.e("ChatViewModel", "Error rechazando", e)
        }
    }
}

Sistema de Valoraciones

Enviar Valoración

Enviar Valoración
fun enviarValoracion(receptorId: String, solicitudId: String, nota: Double) {
    viewModelScope.launch {
        try {
            val requestMsg = EnviarMensajeRequest(
                receptorId = receptorId,
                contenido = "$nota|He dejado una valoración de $nota estrellas.",
                solicitudId = solicitudId,
                tipo = "VALORACION"
            )
            val msgResponse = RetrofitClient.mensajeService.enviarMensaje(requestMsg)
            if (msgResponse.isSuccessful) cargarConversacion(receptorId)
        } catch (e: Exception) {
            android.util.Log.e("ChatViewModel", "Error valorando", e)
        }
    }
}

Diálogo de Valoración

Jetpack Compose
RatingDialog.kt
@Composable
fun RatingDialog(
    onDismiss: () -> Unit,
    onSubmit: (Double) -> Unit
) {
    var rating by remember { mutableStateOf(5.0) }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("Valorar profesional") },
        text = {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text("¿Cómo fue tu experiencia?")
                Spacer(modifier = Modifier.height(16.dp))
                Row {
                    for (i in 1..5) {
                        IconButton(
                            onClick = { rating = i.toDouble() }
                        ) {
                            Icon(
                                imageVector = if (i <= rating) Icons.Filled.Star else Icons.Outlined.Star,
                                contentDescription = null,
                                tint = Color(0xFFFFB74D),
                                modifier = Modifier.size(32.dp)
                            )
                        }
                    }
                }
                Text("$rating estrellas", fontSize = 18.sp, fontWeight = FontWeight.Bold)
            }
        },
        confirmButton = {
            Button(onClick = { onSubmit(rating) }) {
                Text("Enviar valoración")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("Cancelar")
            }
        }
    )
}

Borrar Conversaciones

Borrar Chat y Solicitud
fun borrarChatYSolicitud(
    otroUsuarioId: String,
    solicitudId: String,
    onSuccess: () -> Unit,
    onError: (String) -> Unit
) {
    viewModelScope.launch {
        try {
            // El backend protege el borrado si hay fondos retenidos
            val response = RetrofitClient.solicitudService.eliminarSolicitud(solicitudId)

            if (response.isSuccessful) {
                // Borrar mensajes
                RetrofitClient.mensajeService.borrarConversacion(otroUsuarioId)
                onSuccess()
            } else {
                onError("No se puede borrar este chat porque hay un presupuesto en curso o pagado.")
            }
        } catch (e: Exception) {
            onError("Error de conexión al intentar borrar el chat.")
        }
    }
}

El backend previene el borrado de conversaciones con pagos activos para proteger a ambas partes.

Probar Contratos y Pagos

  1. Crear Solicitud

    Como cliente, ve un servicio y envía una solicitud de contacto.

  2. Enviar Propuesta

    Como profesional, envía una propuesta de precio en el chat.

  3. Aceptar y Pagar

    Como cliente, acepta la propuesta y verifica que los fondos se retienen.

  4. Completar Trabajo

    Como profesional, marca el trabajo como completado.

  5. Aprobar y Liberar

    Como cliente, aprueba el trabajo y verifica que el pago se libera.

  6. Valorar Profesional

    Como cliente, envía una valoración (1-5 estrellas).

Consideraciones de Seguridad

Notas Importantes de Seguridad:
  • Todas las operaciones de pago requieren autenticación (JWT)
  • El sistema escrow previene transferencias directas
  • El backend valida saldo suficiente antes del pago
  • Las solicitudes con fondos retenidos no pueden borrarse
  • Solo el cliente puede liberar el pago tras aprobación

Próximos Pasos