Visión General

El sistema de chat de iMirly utiliza polling para mensajería en tiempo real con tipos de mensajes especiales para negociaciones de contratos:

  • Mensajes de texto
  • Propuestas de precio (PROPUESTA_PRECIO)
  • Aceptación/rechazo de presupuestos
  • Notificaciones de trabajo completado
  • Sistema de valoraciones

Servicio API de Mensajes

Interfaz Retrofit

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

import retrofit2.Response
import retrofit2.http.*

data class EnviarMensajeRequest(
    val receptorId: String,
    val contenido: String,
    val solicitudId: String? = null,
    val tipo: String? = null
)

data class MensajeResponse(
    val id: String,
    val remitenteId: String,
    val receptorId: String,
    val contenido: String,
    val tipo: String?,
    val timestamp: String,
    val leido: Boolean
)

interface MensajeApiService {
    @POST("/api/v1/mensajes")
    suspend fun enviarMensaje(
        @Body request: EnviarMensajeRequest
    ): Response

    @GET("/api/v1/mensajes/conversacion/{otroUsuarioId}")
    suspend fun obtenerConversacion(
        @Path("otroUsuarioId") otroUsuarioId: String
    ): Response>

    @DELETE("/api/v1/mensajes/conversacion/{otroUsuarioId}")
    suspend fun borrarConversacion(
        @Path("otroUsuarioId") otroUsuarioId: String
    ): Response
}

Chat ViewModel con Polling

Implementación Principal

ViewModel
ui/chat/ChatViewModel.kt
package com.imirly.app.ui.chat

import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.imirly.app.network.EnviarMensajeRequest
import com.imirly.app.network.MensajeResponse
import com.imirly.app.network.RetrofitClient
import com.imirly.app.network.TokenManager
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

class ChatViewModel : ViewModel() {
    var mensajes = mutableStateOf>(emptyList())
    var inputText = mutableStateOf("")
    var myUserId = mutableStateOf(null)

    private var pollingJob: Job? = null

    init {
        myUserId.value = TokenManager.getUserId()
    }

    // Iniciar refresco automático cada 3 segundos
    fun startPolling(otroUsuarioId: String) {
        pollingJob?.cancel()
        pollingJob = viewModelScope.launch {
            while (isActive) {
                cargarConversacion(otroUsuarioId)
                delay(3000) // Esperar 3 segundos
            }
        }
    }

    // Detener polling al salir de la pantalla
    fun stopPolling() {
        pollingJob?.cancel()
    }

    private suspend fun cargarConversacion(otroUsuarioId: String) {
        try {
            val response = RetrofitClient.mensajeService.obtenerConversacion(otroUsuarioId)
            if (response.isSuccessful) {
                mensajes.value = response.body() ?: emptyList()
            }
        } catch (e: Exception) {
            android.util.Log.e("ChatViewModel", "Error cargando mensajes", e)
        }
    }

    fun enviarMensaje(receptorId: String) {
        val texto = inputText.value.trim()
        if (texto.isEmpty()) return

        // Limpiar input inmediatamente para feedback rápido
        inputText.value = ""

        viewModelScope.launch {
            try {
                val request = EnviarMensajeRequest(receptorId, texto)
                val response = RetrofitClient.mensajeService.enviarMensaje(request)
                if (response.isSuccessful) {
                    cargarConversacion(receptorId) // Recargar para ver nuestro mensaje
                }
            } catch (e: Exception) {
                android.util.Log.e("ChatViewModel", "Error enviando mensaje", e)
            }
        }
    }
}

El mecanismo de polling refresca la conversación cada 3 segundos. Para producción, considera usar WebSockets o Server-Sent Events para actualizaciones verdaderamente en tiempo real.

Interfaz de Chat

Pantalla Principal de Chat

Jetpack Compose
ui/chat/ChatScreen.kt
package com.imirly.app.ui.chat

import androidx.compose.foundation.background
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.material.icons.Icons
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController

@Composable
fun ChatScreen(
    navController: NavController,
    otroUsuarioId: String,
    nombreOtroUsuario: String,
    viewModel: ChatViewModel = viewModel()
) {
    // Iniciar polling al abrir la pantalla
    LaunchedEffect(otroUsuarioId) {
        viewModel.startPolling(otroUsuarioId)
    }

    // Detener polling al cerrar la pantalla
    DisposableEffect(Unit) {
        onDispose {
            viewModel.stopPolling()
        }
    }

    Column(modifier = Modifier.fillMaxSize()) {
        // Header
        Surface(
            modifier = Modifier.fillMaxWidth(),
            color = Color(0xFF6C5CE7),
            shadowElevation = 4.dp
        ) {
            Row(
                modifier = Modifier.padding(16.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                IconButton(onClick = { navController.popBackStack() }) {
                    Icon(Icons.Default.ArrowBack, contentDescription = null, tint = Color.White)
                }
                Text(
                    nombreOtroUsuario,
                    color = Color.White,
                    fontSize = 18.sp,
                    fontWeight = FontWeight.Bold
                )
            }
        }

        // Lista de mensajes
        LazyColumn(
            modifier = Modifier
                .weight(1f)
                .fillMaxWidth()
                .padding(16.dp),
            reverseLayout = false
        ) {
            items(viewModel.mensajes.value) { mensaje ->
                MessageBubble(
                    mensaje = mensaje,
                    isMyMessage = mensaje.remitenteId == viewModel.myUserId.value
                )
                Spacer(modifier = Modifier.height(8.dp))
            }
        }

        // Campo de input
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            OutlinedTextField(
                value = viewModel.inputText.value,
                onValueChange = { viewModel.inputText.value = it },
                placeholder = { Text("Escribe un mensaje...") },
                modifier = Modifier.weight(1f),
                shape = RoundedCornerShape(24.dp)
            )
            Spacer(modifier = Modifier.width(8.dp))
            IconButton(
                onClick = { viewModel.enviarMensaje(otroUsuarioId) },
                modifier = Modifier
                    .size(48.dp)
                    .background(Color(0xFF6C5CE7), RoundedCornerShape(24.dp))
            ) {
                Icon(Icons.Default.Send, contentDescription = null, tint = Color.White)
            }
        }
    }
}

@Composable
fun MessageBubble(mensaje: MensajeResponse, isMyMessage: Boolean) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = if (isMyMessage) Arrangement.End else Arrangement.Start
    ) {
        Surface(
            shape = RoundedCornerShape(
                topStart = 16.dp,
                topEnd = 16.dp,
                bottomStart = if (isMyMessage) 16.dp else 4.dp,
                bottomEnd = if (isMyMessage) 4.dp else 16.dp
            ),
            color = if (isMyMessage) Color(0xFF6C5CE7) else Color(0xFFE8E8E8),
            modifier = Modifier.widthIn(max = 280.dp)
        ) {
            Column(modifier = Modifier.padding(12.dp)) {
                Text(
                    mensaje.contenido,
                    color = if (isMyMessage) Color.White else Color.Black,
                    fontSize = 14.sp
                )
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    mensaje.timestamp,
                    color = if (isMyMessage) Color.White.copy(alpha = 0.7f) else Color.Gray,
                    fontSize = 10.sp
                )
            }
        }
    }
}

Tipos de Mensajes Especiales

Propuestas de Precio

Enviar Presupuesto
fun enviarPresupuesto(
    receptorId: String,
    solicitudId: String,
    presupuestoContenido: String
) {
    if (presupuestoContenido.isBlank()) return

    viewModelScope.launch {
        try {
            val request = EnviarMensajeRequest(
                receptorId = receptorId,
                contenido = presupuestoContenido, // Formato: "precio|descripción"
                solicitudId = solicitudId,
                tipo = "PROPUESTA_PRECIO"
            )
            val response = RetrofitClient.mensajeService.enviarMensaje(request)
            if (response.isSuccessful) {
                cargarConversacion(receptorId)
            }
        } catch (e: Exception) {
            android.util.Log.e("ChatViewModel", "Error enviando propuesta", e)
        }
    }
}

Aceptar y Pagar

Aceptar y Pagar
fun aceptarYPagar(solicitudId: String, otroUsuarioId: String) {
    viewModelScope.launch {
        try {
            // Procesar pago (retiene fondos en escrow)
            val responsePago = RetrofitClient.solicitudService.pagarSolicitud(solicitudId)

            if (responsePago.isSuccessful) {
                // Enviar mensaje de aceptació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)
                cargarConversacion(otroUsuarioId)
            }
        } catch (e: Exception) {
            android.util.Log.e("ChatViewModel", "Error procesando pago", e)
        }
    }
}

Rechazar Propuesta

Rechazar Presupuesto
fun rechazarPresupuesto(solicitudId: String, otroUsuarioId: String) {
    viewModelScope.launch {
        try {
            val requestEstado = CambiarEstadoRequest("ACEPTADO")
            val response = RetrofitClient.solicitudService
                .cambiarEstadoSolicitud(solicitudId, requestEstado)

            if (response.isSuccessful) {
                val requestMsg = EnviarMensajeRequest(
                    receptorId = otroUsuarioId,
                    contenido = "He rechazado la oferta. Por favor, revisa el precio y envíame un nuevo presupuesto.",
                    solicitudId = solicitudId,
                    tipo = "PRESUPUESTO_RECHAZADO"
                )
                RetrofitClient.mensajeService.enviarMensaje(requestMsg)
                cargarConversacion(otroUsuarioId)
            }
        } catch (e: Exception) {
            android.util.Log.e("ChatViewModel", "Error rechazando", e)
        }
    }
}

Marcar Trabajo Finalizado

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 marcando completado", e)
        }
    }
}

Aprobar Trabajo

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)
        } catch (e: Exception) {
            android.util.Log.e("ChatViewModel", "Error aprobando", e)
        }
    }
}

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)
        }
    }
}

Borrar Conversación

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

            if (response.isSuccessful) {
                // Si se permite, 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.")
        }
    }
}

Bandeja de Entrada

Muestra todas las conversaciones activas:

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

    Column(modifier = Modifier.fillMaxSize()) {
        Text("Mensajes", fontSize = 24.sp, fontWeight = FontWeight.Bold)
        
        LazyColumn {
            items(viewModel.solicitudes.value) { solicitud ->
                ConversationCard(
                    solicitud = solicitud,
                    onClick = {
                        navController.navigate(
                            "chat/${solicitud.clienteId}/${solicitud.nombreCliente}/${solicitud.id}"
                        )
                    }
                )
            }
        }
    }
}

Probar Chat

  1. Enviar Mensaje de Texto

    Abre un chat y envía un mensaje de texto normal.

  2. Verificar Polling

    Abre el chat en dos dispositivos y verifica que los mensajes aparecen en menos de 3 segundos.

  3. Enviar Propuesta de Precio

    Como profesional, envía una propuesta de precio a un cliente.

  4. Aceptar/Rechazar

    Prueba aceptar y rechazar propuestas.

  5. Completar Flujo de Trabajo

    Prueba el flujo completo: propuesta → pago → trabajo completado → aprobación → valoración.

Optimización de Rendimiento

Polling cada 3 segundos puede agotar la batería y usar datos móviles. Para producción:
  • Implementa WebSockets para actualizaciones en tiempo real
  • Usa backoff exponencial cuando la app está en segundo plano
  • Añade caché local con base de datos Room
  • Implementa notificaciones push para nuevos mensajes

Próximos Pasos