Chat en Tiempo Real
Implementa el sistema de mensajería en tiempo real en la aplicación iMirly Android
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 Interfacepackage 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
ViewModelpackage 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 Composepackage 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
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
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
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
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
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
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
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:
@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
-
Enviar Mensaje de Texto
Abre un chat y envía un mensaje de texto normal.
-
Verificar Polling
Abre el chat en dos dispositivos y verifica que los mensajes aparecen en menos de 3 segundos.
-
Enviar Propuesta de Precio
Como profesional, envía una propuesta de precio a un cliente.
-
Aceptar/Rechazar
Prueba aceptar y rechazar propuestas.
-
Completar Flujo de Trabajo
Prueba el flujo completo: propuesta → pago → trabajo completado → aprobación → valoración.
Optimización de Rendimiento
- 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