From 682b16748c25f4af7118ea55eec9e1baea7eb980 Mon Sep 17 00:00:00 2001 From: Miguel4950 Date: Mon, 1 Jun 2026 19:12:28 -0500 Subject: [PATCH 1/2] Implementar gestion de amigos, chat 1-1, chat grupal contextual y mejoras de rumbo --- app/src/main/AndroidManifest.xml | 1 + .../rumbo/models/chatConversation.kt | 19 ++ .../rumbo/models/chatMessage.kt | 12 + .../rumbo/models/placeState.kt | 5 +- .../com/appnotresponding/rumbo/models/user.kt | 7 +- .../rumbo/navigation/navigation.kt | 47 +-- .../components/molecules/chat/ChatBubble.kt | 195 ++++++++++-- .../molecules/chat/MessageComposer.kt | 2 +- .../molecules/friends/FriendRequestItem.kt | 85 +++++ .../molecules/friends/UserSearchResultItem.kt | 94 ++++++ .../components/organisms/chat/ChatThread.kt | 2 + .../ui/components/organisms/common/Nav.kt | 2 +- .../ui/components/organisms/common/TopBar.kt | 78 ++++- .../organisms/friends/FriendsList.kt | 53 ++++ .../rumbo/ui/screens/chat/ChatListScreen.kt | 249 +++++++++++---- .../rumbo/ui/screens/chat/ChatThreadScreen.kt | 296 ++++++++++++----- .../rumbo/ui/screens/friends/FriendsScreen.kt | 152 +++++++++ .../rumbo/ui/screens/map/MapScreen.kt | 8 +- .../rumbo/ui/templates/ChatThreadTemplate.kt | 36 ++- .../rumbo/ui/templates/FriendsTemplate.kt | 59 ++++ .../rumbo/ui/templates/MapTemplate.kt | 71 ++++- .../rumbo/ui/viewModel/chatThreadViewModel.kt | 272 ++++++++++++++++ .../rumbo/ui/viewModel/chatViewModel.kt | 298 ++++++++++++++++++ .../rumbo/ui/viewModel/friendsViewModel.kt | 248 +++++++++++++++ .../rumbo/ui/viewModel/placesViewModel.kt | 8 + .../rumbo/ui/viewModel/userViewModel.kt | 44 ++- 26 files changed, 2113 insertions(+), 230 deletions(-) create mode 100644 app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/friends/FriendsList.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1902b82..593be21 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + = emptyMap() +) diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt b/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt new file mode 100644 index 0000000..fdf4914 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt @@ -0,0 +1,12 @@ +package com.appnotresponding.rumbo.models + +data class ChatMessage( + val id: String = "", + val senderId: String = "", + val senderName: String = "", + val text: String = "", + val timestamp: Long = 0, + val type: String = "text", + val placeId: String? = null, + val mediaUrl: String? = null +) diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt index d94f998..8bc544e 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt @@ -1,7 +1,10 @@ package com.appnotresponding.rumbo.models +import com.google.android.gms.maps.model.LatLng + data class PlaceState( val availablePlaces: List = emptyList(), val itinerary: List = emptyList(), - val selectedPlace: Place? = null + val selectedPlace: Place? = null, + val focusLocation: LatLng? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/user.kt b/app/src/main/java/com/appnotresponding/rumbo/models/user.kt index 419bab2..5b5c671 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/user.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/user.kt @@ -10,7 +10,9 @@ data class User( val latitude: Double = 0.0, val longitude: Double = 0.0, val altitude: Double = 0.0, - val profilePictureUrl: String? = null + val profilePictureUrl: String? = null, + val sharingLocation: Boolean = false, + val activity: String? = null ) val sampleUser = User( @@ -22,5 +24,6 @@ val sampleUser = User( latitude = 0.0, longitude = 0.0, altitude = 0.0, - profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg" + profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg", + sharingLocation = false ) \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt index 526fd99..941118b 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt @@ -1,7 +1,6 @@ package com.appnotresponding.rumbo.navigation import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -10,20 +9,25 @@ import com.appnotresponding.rumbo.ui.screens.auth.LogInScreen import com.appnotresponding.rumbo.ui.screens.auth.SignUpScreen import com.appnotresponding.rumbo.ui.screens.chat.ChatListScreen import com.appnotresponding.rumbo.ui.screens.chat.ChatThreadScreen +import com.appnotresponding.rumbo.ui.screens.friends.FriendsScreen import com.appnotresponding.rumbo.ui.screens.itinerary.ItineraryScreen import com.appnotresponding.rumbo.ui.screens.map.MapScreen import com.appnotresponding.rumbo.ui.screens.onboarding.OnBoardingScreen import com.appnotresponding.rumbo.ui.screens.plan.PlanScreen import com.appnotresponding.rumbo.ui.screens.splash.SplashScreen +import com.appnotresponding.rumbo.ui.viewModel.ChatThreadViewModel +import com.appnotresponding.rumbo.ui.viewModel.ChatViewModel +import com.appnotresponding.rumbo.ui.viewModel.FriendsViewModel import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel -import androidx.lifecycle.ViewModel import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel - import com.appnotresponding.rumbo.ui.viewModel.UserViewModel val placesViewModel: PlacesViewModel = PlacesViewModel() +val chatViewModel: ChatViewModel = ChatViewModel() +val chatThreadViewModel: ChatThreadViewModel = ChatThreadViewModel() +val friendsViewModel: FriendsViewModel = FriendsViewModel() -enum class AppScreens{ +enum class AppScreens { Splash, LogIn, SignUp, @@ -32,43 +36,46 @@ enum class AppScreens{ ChatThread, Plan, Itinerary, - OnBoarding + OnBoarding, + Friends } @Composable fun Navigation( locationViewModel: UserLocationViewModel = viewModel(), userViewModel: UserViewModel = viewModel() -){ - val context = LocalContext.current +) { val navController = rememberNavController() - NavHost(navController=navController, startDestination = AppScreens.Splash.name){ - composable (route = AppScreens.Splash.name){ + NavHost(navController = navController, startDestination = AppScreens.Splash.name) { + composable(route = AppScreens.Splash.name) { SplashScreen(navController) } - composable(route = AppScreens.LogIn.name){ + composable(route = AppScreens.LogIn.name) { LogInScreen(navController) } - composable (route = AppScreens.SignUp.name){ + composable(route = AppScreens.SignUp.name) { SignUpScreen(navController) } - composable (route = AppScreens.Map.name) { - MapScreen(navController, placesViewModel, locationViewModel, userViewModel) + composable(route = AppScreens.Map.name) { + MapScreen(navController, placesViewModel, locationViewModel, userViewModel, friendsViewModel) } - composable (route = AppScreens.Chat.name) { - ChatListScreen(navController, userViewModel) + composable(route = AppScreens.Chat.name) { + ChatListScreen(navController, userViewModel, chatViewModel, placesViewModel) } - composable(route = AppScreens.ChatThread.name){ - ChatThreadScreen(navController) + composable(route = AppScreens.ChatThread.name) { + ChatThreadScreen(navController, chatViewModel, chatThreadViewModel, userViewModel, locationViewModel, placesViewModel) } - composable(route = AppScreens.Plan.name){ + composable(route = AppScreens.Plan.name) { PlanScreen(navController, placesViewModel, locationViewModel, userViewModel) } - composable(route = AppScreens.Itinerary.name){ + composable(route = AppScreens.Itinerary.name) { ItineraryScreen(navController, placesViewModel, userViewModel) } - composable(route = AppScreens.OnBoarding.name){ + composable(route = AppScreens.OnBoarding.name) { OnBoardingScreen(navController) } + composable(route = AppScreens.Friends.name) { + FriendsScreen(navController, userViewModel, friendsViewModel, chatViewModel) + } } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt index d2f49ab..ad69ace 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt @@ -2,6 +2,7 @@ package com.appnotresponding.rumbo.ui.components.molecules.chat import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,11 +11,17 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -23,6 +30,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage @@ -35,6 +44,8 @@ import com.appnotresponding.rumbo.ui.components.atoms.RumboButton import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonSize import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle import com.appnotresponding.rumbo.ui.theme.RumboTheme +import android.media.MediaPlayer +import androidx.compose.ui.unit.sp @Composable fun ChatSeparator(text: String) { @@ -82,11 +93,14 @@ enum class ChatBubbleType { @Composable fun ChatBubble( message: String, - messageImage: ImageRequest? = null, + mediaUrl: String? = null, + mediaType: String? = null, isUserMessage: Boolean, senderName: String? = null, + senderActivity: String? = null, type: ChatBubbleType = ChatBubbleType.Regular, - place: Place? = null + place: Place? = null, + onLocationClick: (() -> Unit)? = null ) { val horizontalAlignment = if (isUserMessage) { Alignment.End @@ -112,6 +126,22 @@ fun ChatBubble( Arrangement.Start } + val bubbleShape = if (isUserMessage) { + RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = 16.dp, + bottomEnd = 4.dp + ) + } else { + RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = 4.dp, + bottomEnd = 16.dp + ) + } + when (type) { ChatBubbleType.Regular -> { Row( @@ -120,36 +150,125 @@ fun ChatBubble( Column( modifier = Modifier .widthIn(max = 280.dp) - .background(backgroundColor, MaterialTheme.shapes.large), + .then( + if (mediaUrl != null) Modifier.width(240.dp) else Modifier + ) + .background(backgroundColor, bubbleShape), horizontalAlignment = horizontalAlignment, verticalArrangement = Arrangement.spacedBy(4.dp), ) { Column( - modifier = Modifier.padding(12.dp), - horizontalAlignment = horizontalAlignment, + modifier = Modifier + .then(if (mediaUrl != null) Modifier.fillMaxWidth() else Modifier) + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (senderName != null) { - Text( - text = senderName, - style = MaterialTheme.typography.labelLarge, - color = contentColor - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 2.dp) + ) { + Text( + text = senderName, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = contentColor.copy(alpha = 0.8f) + ) + if (!senderActivity.isNullOrBlank()) { + Text( + text = " · ", + style = MaterialTheme.typography.labelMedium, + color = contentColor.copy(alpha = 0.6f) + ) + Text( + text = senderActivity, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = contentColor + ) + } + } } - if (messageImage != null) { + if (mediaUrl != null && mediaType == "image") { AsyncImage( - modifier = Modifier.clip(MaterialTheme.shapes.medium), - model = messageImage, + modifier = Modifier.clip(MaterialTheme.shapes.medium).fillMaxWidth(), + model = mediaUrl, + contentScale = ContentScale.FillWidth, contentDescription = null ) + } else if (mediaUrl != null && mediaType == "audio") { + var isPlaying by remember { mutableStateOf(false) } + var isPreparing by remember { mutableStateOf(false) } + val mediaPlayer = remember { MediaPlayer() } + + DisposableEffect(mediaUrl) { + onDispose { + mediaPlayer.release() + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium) + .clickable { + try { + if (isPlaying) { + mediaPlayer.stop() + mediaPlayer.reset() + isPlaying = false + } else if (!isPreparing) { + isPreparing = true + mediaPlayer.reset() + mediaPlayer.setDataSource(mediaUrl) + mediaPlayer.setOnPreparedListener { + isPreparing = false + isPlaying = true + mediaPlayer.start() + } + mediaPlayer.setOnCompletionListener { + isPlaying = false + } + mediaPlayer.setOnErrorListener { _, _, _ -> + isPreparing = false + isPlaying = false + true + } + mediaPlayer.prepareAsync() + } + } catch (e: Exception) { + e.printStackTrace() + isPreparing = false + isPlaying = false + } + } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + val icon = if (isPreparing) "⏳" else if (isPlaying) "⏸" else "▶" + Text(icon, color = MaterialTheme.colorScheme.primary) + Text( + text = if (isPreparing) "Preparando..." else if (isPlaying) "Reproduciendo..." else "Nota de voz", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + val isMediaPlaceholder = mediaUrl != null && (message == "📷 Imagen" || message == "🎤 Nota de voz") + if (!isMediaPlaceholder && message.isNotBlank()) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge.copy( + lineHeight = 22.sp + ), + color = contentColor + ) } - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = contentColor - ) } } } @@ -165,9 +284,10 @@ fun ChatBubble( .widthIn(max = 280.dp) .background( backgroundColor, - MaterialTheme.shapes.large + bubbleShape ) - .clip(MaterialTheme.shapes.large), + .clip(bubbleShape) + .clickable(enabled = onLocationClick != null) { onLocationClick?.invoke() }, ) { Row( modifier = Modifier @@ -211,7 +331,7 @@ fun ChatBubble( Column( modifier = Modifier .widthIn(max = 280.dp) - .background(backgroundColor, MaterialTheme.shapes.large), + .background(backgroundColor, bubbleShape), horizontalAlignment = horizontalAlignment, verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -222,11 +342,28 @@ fun ChatBubble( verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (senderName != null) { - Text( - text = senderName, - style = MaterialTheme.typography.labelLarge, - color = contentColor - ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = senderName, + style = MaterialTheme.typography.labelLarge, + color = contentColor + ) + if (!senderActivity.isNullOrBlank()) { + Text( + text = " · ", + style = MaterialTheme.typography.labelLarge, + color = contentColor.copy(alpha = 0.6f) + ) + Text( + text = senderActivity, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = contentColor + ) + } + } } Text( @@ -307,7 +444,8 @@ private fun ChatBubblePreviewContent() { // Regular - received with image ChatBubble( message = "¡Hola! ¿Cómo estás?", - messageImage = placeholderImage, + mediaUrl = null, + mediaType = null, isUserMessage = false, senderName = "Carlos", type = ChatBubbleType.Regular @@ -315,7 +453,8 @@ private fun ChatBubblePreviewContent() { // Regular - sent with image ChatBubble( message = "¡Todo bien! ¿Y tú?", - messageImage = placeholderImage, + mediaUrl = null, + mediaType = null, isUserMessage = true, senderName = null, type = ChatBubbleType.Regular diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt index 7c203e5..004fca5 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt @@ -102,7 +102,7 @@ fun MessageComposer( IconButton(onClick = onLocationClick, modifier = Modifier.size(40.dp)) { Icon( painter = painterResource(id = R.drawable.ic_marker), - contentDescription = "Enviar ubicación", + contentDescription = "Compartir ubicación", modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt new file mode 100644 index 0000000..0e5b70c --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt @@ -0,0 +1,85 @@ +package com.appnotresponding.rumbo.ui.components.molecules.friends + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.appnotresponding.rumbo.R +import com.appnotresponding.rumbo.models.User +import com.appnotresponding.rumbo.ui.components.atoms.Avatar +import com.appnotresponding.rumbo.ui.components.atoms.RumboButton +import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonSize +import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle + +@Composable +fun FriendRequestItem( + user: User, + onAcceptClick: () -> Unit, + onDeclineClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + shape = MaterialTheme.shapes.medium + ) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.medium + ) + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(user = user) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "${user.name} ${user.lastname}", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Te envió una solicitud", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RumboButton( + text = "Aceptar", + onClick = onAcceptClick, + style = RumboButtonStyle.Primary, + size = RumboButtonSize.Small, + icon = painterResource(R.drawable.ic_check) + ) + RumboButton( + text = "Rechazar", + onClick = onDeclineClick, + style = RumboButtonStyle.Secondary, + size = RumboButtonSize.Small, + icon = painterResource(R.drawable.outline_cancel_24) + ) + } + } + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt new file mode 100644 index 0000000..0c137a7 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt @@ -0,0 +1,94 @@ +package com.appnotresponding.rumbo.ui.components.molecules.friends + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.appnotresponding.rumbo.R +import com.appnotresponding.rumbo.models.User +import com.appnotresponding.rumbo.ui.components.atoms.Avatar +import com.appnotresponding.rumbo.ui.components.atoms.RumboButton +import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonSize +import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle + +@Composable +fun UserSearchResultItem( + user: User, + isAlreadyFriend: Boolean, + isPending: Boolean = false, + onAddClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.medium + ) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = MaterialTheme.shapes.medium + ) + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(user = user) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "${user.name} ${user.lastname}", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = user.email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (isAlreadyFriend) { + RumboButton( + text = "Amigos", + onClick = {}, + enabled = false, + style = RumboButtonStyle.Secondary, + size = RumboButtonSize.Small, + icon = painterResource(R.drawable.ic_check) + ) + } else if (isPending) { + RumboButton( + text = "Pendiente", + onClick = {}, + enabled = false, + style = RumboButtonStyle.Secondary, + size = RumboButtonSize.Small, + icon = painterResource(R.drawable.ic_user) + ) + } else { + RumboButton( + text = "Agregar", + onClick = onAddClick, + style = RumboButtonStyle.Primary, + size = RumboButtonSize.Small, + icon = painterResource(R.drawable.ic_user_add) + ) + } + } + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatThread.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatThread.kt index 61a5434..1fed3a5 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatThread.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatThread.kt @@ -18,6 +18,7 @@ data class ChatMessageData( val message: String, val isUserMessage: Boolean, val senderName: String? = null, + val senderActivity: String? = null, val type: ChatBubbleType = ChatBubbleType.Regular, val place: Place? = null, val isSeparator: Boolean = false @@ -44,6 +45,7 @@ fun ChatThread( message = msg.message, isUserMessage = msg.isUserMessage, senderName = msg.senderName, + senderActivity = msg.senderActivity, type = msg.type, place = msg.place ) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt index a9681b6..cf38d8d 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt @@ -47,7 +47,7 @@ fun Nav( val currentRoute = navBackStackEntry?.destination?.route val activeItem = when (currentRoute) { AppScreens.Map.name -> NavItem.Map - AppScreens.Chat.name, AppScreens.ChatThread.name -> NavItem.Chat + AppScreens.Chat.name, AppScreens.ChatThread.name, AppScreens.Friends.name -> NavItem.Chat AppScreens.Plan.name -> NavItem.Plan AppScreens.Itinerary.name -> NavItem.Itinerary else -> NavItem.Map diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt index 35112ac..2ac68f4 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt @@ -9,6 +9,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ExitToApp +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -78,7 +85,15 @@ fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) { * (por ejemplo, "Rumbo al Museo Nacional"). */ @Composable -fun ChatTopBar(u: User, activity: String? = null) { +fun ChatTopBar( + u: User, + activity: String? = null, + isGroup: Boolean = false, + isMuted: Boolean = false, + onMuteClick: (() -> Unit)? = null, + onLeaveClick: (() -> Unit)? = null, + onBackClick: (() -> Unit)? = null +) { val bottomRoundedShape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) val displayName = u.name.replace(Regex(" +$"), "") Surface( @@ -89,24 +104,57 @@ fun ChatTopBar(u: User, activity: String? = null) { .fillMaxWidth() .padding(16.dp) .padding(top = 32.dp), - horizontalArrangement = Arrangement.Start + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Avatar(user = u) - Column { - - Text( - text = displayName, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(start = 8.dp), - color = MaterialTheme.colorScheme.onSurface - ) - if (activity != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (onBackClick != null) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Atrás", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + Avatar(user = u) + Column { Text( - text = activity, - style = MaterialTheme.typography.labelMedium, + text = displayName, + style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(start = 8.dp), - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.onSurface ) + if (!activity.isNullOrBlank()) { + Text( + text = activity, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 8.dp), + color = MaterialTheme.colorScheme.primary + ) + } + } + } + if (isGroup) { + Row { + if (onMuteClick != null) { + IconButton(onClick = onMuteClick) { + Icon( + imageVector = if (isMuted) Icons.Filled.NotificationsOff else Icons.Filled.Notifications, + contentDescription = if (isMuted) "Desilenciar" else "Silenciar", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (onLeaveClick != null) { + IconButton(onClick = onLeaveClick) { + Icon( + imageVector = Icons.Filled.ExitToApp, + contentDescription = "Salir", + tint = MaterialTheme.colorScheme.error + ) + } + } } } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/friends/FriendsList.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/friends/FriendsList.kt new file mode 100644 index 0000000..a7f14ac --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/friends/FriendsList.kt @@ -0,0 +1,53 @@ +package com.appnotresponding.rumbo.ui.components.organisms.friends + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.appnotresponding.rumbo.models.User +import com.appnotresponding.rumbo.ui.components.molecules.friends.UserSearchResultItem + +@Composable +fun FriendsList( + friends: List, + modifier: Modifier = Modifier, + onFriendClick: (User) -> Unit = {} +) { + if (friends.isEmpty()) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Aún no tienes amigos en Rumbo", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + return + } + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 100.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(friends) { friend -> + UserSearchResultItem( + modifier = Modifier.clickable { onFriendClick(friend) }, + user = friend, + isAlreadyFriend = true, + onAddClick = {} + ) + } + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt index a9f69d5..1ca3ba2 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt @@ -1,85 +1,204 @@ package com.appnotresponding.rumbo.ui.screens.chat - +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController +import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.auth +import com.appnotresponding.rumbo.models.User import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens -import com.appnotresponding.rumbo.ui.components.organisms.chat.ChatList -import com.appnotresponding.rumbo.ui.components.organisms.chat.ChatPreviewData -import com.appnotresponding.rumbo.ui.templates.ChatTemplate +import com.appnotresponding.rumbo.ui.components.molecules.chat.ChatListItem +import com.appnotresponding.rumbo.ui.components.organisms.common.MainTopBar +import com.appnotresponding.rumbo.ui.components.organisms.common.Nav +import com.appnotresponding.rumbo.ui.viewModel.ChatViewModel +import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel import com.appnotresponding.rumbo.ui.viewModel.UserViewModel - +import com.google.firebase.auth.FirebaseAuth @Composable -fun ChatListScreen(controller: NavHostController, userViewModel: UserViewModel) { +fun ChatListScreen( + controller: NavHostController, + userViewModel: UserViewModel, + chatViewModel: ChatViewModel, + placesViewModel: PlacesViewModel +) { val userState by userViewModel.currentUserState.collectAsState() val currentUser = userState ?: sampleUser.copy(name = "Cargando...") + val chatState by chatViewModel.uiState.collectAsState() + val placesState by placesViewModel.uiState.collectAsState() + + LaunchedEffect(placesState.itinerary) { + chatViewModel.listenToGroupChats(placesState.itinerary) + } + + val myUid = FirebaseAuth.getInstance().currentUser?.uid ?: "" - val mockChats = listOf( - ChatPreviewData( - sampleUser.copy(name = "Brandon"), - "¡Ya estoy cerca! ...", - "Rumbo al Museo Nacional", - "", - true - ), - ChatPreviewData( - sampleUser.copy(name = "Aylean"), - "¿Nos vemos allá?", - "Rumbo al Museo Nacional", - "", - false - ), - ChatPreviewData( - sampleUser.copy(name = "Ahbdul"), - "¡Ya estoy cerca! ...", - "Rumbo al Museo Nacional", - "", - false - ), - ChatPreviewData( - sampleUser.copy(name = "Los Mochileros"), - "@Ana, dónde estás?!", - "Rumbo al Museo N...", - "", - true - ), - ChatPreviewData(sampleUser.copy(name = "Kyle"), "Fué un gusto conocerte!", null, "", false), - ChatPreviewData(sampleUser.copy(name = "Ashley"), "¡Ya estoy cerca! ...", null, "", false), - ChatPreviewData(sampleUser.copy(name = "Tatiana"), "¡Ya estoy cerca! ...", null, "", false) - ) + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + MainTopBar(u = currentUser, onProfileClick = { + auth.signOut() + controller.navigate(AppScreens.Splash.name) { + popUpTo(controller.graph.startDestinationId) { inclusive = true } + launchSingleTop = true + } + }) + }, + bottomBar = { Nav(controller) }, + floatingActionButton = { + FloatingActionButton( + onClick = { controller.navigate(AppScreens.Friends.name) }, + containerColor = MaterialTheme.colorScheme.primary + ) { + Icon( + painter = painterResource(R.drawable.ic_user_add), + contentDescription = "Amigos", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)) { + Text( + text = "Chats", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Mensajes en tiempo real", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (chatState.directChats.isNotEmpty()) { + item { + Text( + text = "Directos", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + items(chatState.directChats) { convo -> + val friendUser = User( + id = convo.otherUserId, + name = convo.otherUserName, + profilePictureUrl = convo.otherUserPhotoUrl, + activity = convo.otherUserActivity + ) + ChatListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { + chatViewModel.selectDirectChat( + chatId = convo.chatId, + chatTitle = convo.otherUserName, + photoUrl = convo.otherUserPhotoUrl + ) + controller.navigate(AppScreens.ChatThread.name) + }, + user = friendUser, + lastMessage = convo.lastMessage, + status = convo.otherUserActivity, + timestamp = formatTimestamp(convo.lastMessageTimestamp), + hasUnread = false + ) + } + } - ChatTemplate( - currentUser = currentUser, - title = "Chats", - subtitle = "Ubicación actual: Bogotá", - controller = controller, - onProfileClick = { - auth.signOut() - controller.navigate(AppScreens.Splash.name) { - popUpTo(controller.graph.startDestinationId) { inclusive = true } - launchSingleTop = true + if (chatState.groupChats.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Grupos", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + items(chatState.groupChats) { group -> + val isMuted = group.mutedBy[myUid] == true + val groupUser = User(name = group.placeName) + ChatListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { + chatViewModel.selectGroupChat( + placeId = group.placeId, + placeName = group.placeName + ) + controller.navigate(AppScreens.ChatThread.name) + }, + user = groupUser, + lastMessage = if (isMuted) "🔇 Silenciado" else group.lastMessage, + status = "Grupo", + timestamp = formatTimestamp(group.lastMessageTimestamp), + hasUnread = false + ) + } + } + + if (chatState.directChats.isEmpty() && chatState.groupChats.isEmpty()) { + item { + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = "No tienes chats aún.\nAgrega amigos con el botón +", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } } - }) { - ChatList( - chatItems = mockChats, - onChatClick = { controller.navigate(AppScreens.ChatThread.name) }) + } } } -// -//@Preview( -// showBackground = true, -// name = "3. Pantalla Lista de Chats demostracion", -// backgroundColor = 0xFF121212 -//) -//@Composable -//private fun ChatListScreenPreview() { -// RumboTheme(darkTheme = true) { -// ChatListScreen(controller = rememberNavController()) -// } -//} \ No newline at end of file +private fun formatTimestamp(timestamp: Long): String { + if (timestamp == 0L) return "" + val now = System.currentTimeMillis() + val diff = now - timestamp + return when { + diff < 60_000 -> "Ahora" + diff < 3_600_000 -> "${diff / 60_000}m" + diff < 86_400_000 -> "${diff / 3_600_000}h" + else -> "${diff / 86_400_000}d" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt index 945c4d0..83e0ff1 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt @@ -1,112 +1,240 @@ package com.appnotresponding.rumbo.ui.screens.chat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import android.Manifest +import android.content.pm.PackageManager +import android.media.MediaRecorder +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.core.content.ContextCompat +import androidx.compose.ui.platform.LocalContext +import java.io.File import androidx.compose.runtime.setValue -import androidx.compose.ui.tooling.preview.Preview -import androidx.navigation.NavController +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController -import com.appnotresponding.rumbo.models.samplePlace import com.appnotresponding.rumbo.models.sampleUser -import com.appnotresponding.rumbo.navigation.AppScreens +import com.appnotresponding.rumbo.ui.components.molecules.chat.ChatBubble import com.appnotresponding.rumbo.ui.components.molecules.chat.ChatBubbleType -import com.appnotresponding.rumbo.ui.components.organisms.chat.ChatMessageData -import com.appnotresponding.rumbo.ui.components.organisms.chat.ChatThread import com.appnotresponding.rumbo.ui.templates.ChatThreadTemplate -import com.appnotresponding.rumbo.ui.theme.RumboTheme +import com.appnotresponding.rumbo.ui.viewModel.ChatThreadViewModel +import com.appnotresponding.rumbo.ui.viewModel.ChatViewModel +import com.appnotresponding.rumbo.ui.viewModel.UserViewModel +import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel +import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel +import com.appnotresponding.rumbo.navigation.AppScreens @Composable fun ChatThreadScreen( - controller: NavHostController + controller: NavHostController, + chatViewModel: ChatViewModel, + chatThreadViewModel: ChatThreadViewModel, + userViewModel: UserViewModel, + locationViewModel: UserLocationViewModel, + placesViewModel: PlacesViewModel ) { + val chatState by chatViewModel.uiState.collectAsState() + val threadState by chatThreadViewModel.uiState.collectAsState() + val userState by userViewModel.currentUserState.collectAsState() + val currentUser = userState ?: sampleUser + val locationState by locationViewModel.uiState.collectAsState() + var messageInput by remember { mutableStateOf("") } - val brandonUser = sampleUser.copy(name = "Brandon") + val listState = rememberLazyListState() - val museoNacional = samplePlace.copy( - name = "Museo Nacional", - openHours = emptyList(), - price = "$ 40.000 COP" - ) + val chatId = chatState.selectedChatId + val isGroup = chatState.isGroupChat - val messages = listOf( - ChatMessageData("Hola! Vamos al Museo Nacional?", isUserMessage = true), - ChatMessageData("Hola! De una!", isUserMessage = false), - ChatMessageData("", isUserMessage = false, type = ChatBubbleType.LiveActivity, place = museoNacional), - ChatMessageData("Iniciaste una ruta compartida", isUserMessage = false, isSeparator = true), - ChatMessageData("Ya estoy en camino!", isUserMessage = true), - ChatMessageData("¡Ya estoy cerca!\nTe parece si nos vemos en la entrada?", isUserMessage = false) - ) - ChatThreadTemplate( - chatTitle = brandonUser.name, - chatSubtitle = "", - chatAvatarUser = brandonUser, - messageInputValue = messageInput, - onMessageInputValueChange = { messageInput = it }, - onSendClick = { - messageInput = "" - }) { - ChatThread(messages = messages) - } -} + var mediaRecorder by remember { mutableStateOf(null) } + var audioFile by remember { mutableStateOf(null) } + var isRecording by remember { mutableStateOf(false) } -@Preview(showBackground = true, name = "4A. Hilo de Chat (1 a 1) - Demo", backgroundColor = 0xFF121212, heightDp = 800) -@Composable -fun ChatThreadOneOnOnePreview() { - val brandonUser = sampleUser.copy(name = "Brandon") + val context = LocalContext.current - val museoNacional = samplePlace.copy( - name = "Museo Nacional", openHours = emptyList(), price = "$ 40.000 COP" - ) + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + if (uri != null) { + chatThreadViewModel.sendMediaMessage(chatId, currentUser.name, uri, isGroup, "image") + } + } - val mockMessages = listOf( - ChatMessageData("Hola! Vamos al Museo Nacional?", isUserMessage = true), - ChatMessageData("Hola! De una!", isUserMessage = false), - ChatMessageData( - "", isUserMessage = false, type = ChatBubbleType.LiveActivity, place = museoNacional - ), - ChatMessageData("Iniciaste una ruta compartida", isUserMessage = false, isSeparator = true), - ChatMessageData("Ya estoy en camino!", isUserMessage = true), - ChatMessageData( - "¡Ya estoy cerca!\nTe parece si nos vemos en la entrada?", isUserMessage = false - ) - ) + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + // Handle permission result if needed + } - RumboTheme(darkTheme = true) { - ChatThreadScreen(controller = rememberNavController()) + LaunchedEffect(chatId) { + if (chatId.isNotBlank()) { + if (isGroup) { + chatThreadViewModel.listenToGroupMessages(chatId) + } else { + chatThreadViewModel.listenToMessages(chatId) + } + } } -} -@Preview( - showBackground = true, - name = "4.1. Hilo de Chat Grupal demostrcion", - backgroundColor = 0xFF121212, - heightDp = 800 -) -@Composable -fun ChatThreadGroupPreview() { - val groupAvatar = sampleUser.copy(name = "Grupo") - - val mockGroupMessages = listOf( - ChatMessageData("Hola! Cómo van??", isUserMessage = true), - ChatMessageData( - "Hola! Yo estoy saliendo del hotel", isUserMessage = false, senderName = "Brandon" - ), - ChatMessageData( - "Yo ya llegué, acá los espero", isUserMessage = false, senderName = "Ahbdul" - ), - ChatMessageData("@Ashley, dónde vienes?", isUserMessage = true), - ChatMessageData("Creo que estoy perdida 😭", isUserMessage = false, senderName = "Ashley"), - ChatMessageData("Mentira, ya estoy con los demás", isUserMessage = true), - ChatMessageData("@Ana, dónde estás?!", isUserMessage = false, senderName = "Ana"), - ChatMessageData("", isUserMessage = true, type = ChatBubbleType.Location) + LaunchedEffect(threadState.messages.size) { + if (threadState.messages.isNotEmpty()) { + listState.animateScrollToItem(threadState.messages.size - 1) + } + } + + val avatarUser = sampleUser.copy( + name = chatState.selectedChatTitle, + profilePictureUrl = chatState.selectedChatPhoto ) - RumboTheme(darkTheme = true) { - ChatThreadScreen( - controller = rememberNavController() - ) + val isMuted = chatState.groupChats.find { it.placeId == chatId }?.mutedBy?.get(currentUser.id) == true + + val otherUid = chatId.split("_").firstOrNull { it != currentUser.id } + val otherUser = threadState.messageAuthors[otherUid] + + ChatThreadTemplate( + chatTitle = chatState.selectedChatTitle, + chatSubtitle = if (isGroup) "Chat grupal" else (otherUser?.activity ?: ""), + chatAvatarUser = avatarUser, + isGroup = isGroup, + isMuted = isMuted, + onMuteClick = { + if (isMuted) { + chatViewModel.unmuteGroup(chatId) + } else { + chatViewModel.muteGroup(chatId) + } + }, + onLeaveClick = { + chatViewModel.leaveGroup(chatId) + controller.navigateUp() + }, + onBackClick = { + controller.navigateUp() + }, + messageInputValue = messageInput, + onMessageInputValueChange = { messageInput = it }, + onSendClick = { + val text = messageInput.trim() + if (text.isNotBlank()) { + if (isGroup) { + chatThreadViewModel.sendGroupMessage(chatId, currentUser.name, text) + } else { + chatThreadViewModel.sendMessage(chatId, text) + } + messageInput = "" + } + }, + onImageClick = { + imagePickerLauncher.launch("image/*") + }, + onLocationClick = { + val lat = locationState.latitude + val lng = locationState.longitude + val finalLat = if (lat != 0.0) lat else 4.627293 + val finalLng = if (lng != 0.0) lng else -74.063228 + chatThreadViewModel.sendLocationMessage(chatId, currentUser.name, finalLat, finalLng, isGroup) + }, + onMicClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + if (isRecording) { + mediaRecorder?.stop() + mediaRecorder?.release() + mediaRecorder = null + isRecording = false + audioFile?.let { file -> + chatThreadViewModel.sendMediaMessage(chatId, currentUser.name, Uri.fromFile(file), isGroup, "audio") + } + } else { + audioFile = File.createTempFile("audio", ".mp4", context.cacheDir) + val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + recorder.setAudioSource(MediaRecorder.AudioSource.MIC) + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + recorder.setOutputFile(audioFile!!.absolutePath) + try { + recorder.prepare() + recorder.start() + mediaRecorder = recorder + isRecording = true + } catch (e: Exception) { + e.printStackTrace() + } + } + } else { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + ) { + if (threadState.messages.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "Sin mensajes aún. ¡Di hola!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + state = listState, + contentPadding = PaddingValues(top = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(threadState.messages) { msg -> + val isMine = msg.senderId == currentUser.id + val author = threadState.messageAuthors[msg.senderId] + val activity = author?.activity + val bubbleType = if (msg.type == "location") ChatBubbleType.Location else ChatBubbleType.Regular + val onLocClick: (() -> Unit)? = if (msg.type == "location") { + { + val parts = msg.text.removePrefix("Ubicación: ").split(",") + if (parts.size == 2) { + val lat = parts[0].trim().toDoubleOrNull() + val lng = parts[1].trim().toDoubleOrNull() + if (lat != null && lng != null) { + placesViewModel.focusOnLocation(com.google.android.gms.maps.model.LatLng(lat, lng)) + controller.navigate(AppScreens.Map.name) + } + } + } + } else null + + val displayName = author?.name?.takeIf { it.isNotBlank() } ?: msg.senderName + ChatBubble( + message = msg.text, + mediaUrl = msg.mediaUrl, + mediaType = msg.type, + isUserMessage = isMine, + senderName = if (!isMine && isGroup && displayName.isNotBlank()) displayName else null, + senderActivity = if (!isMine && isGroup) activity else null, + type = bubbleType, + onLocationClick = onLocClick + ) + } + } + } } } + + diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt new file mode 100644 index 0000000..f2d264b --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt @@ -0,0 +1,152 @@ +package com.appnotresponding.rumbo.ui.screens.friends + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.appnotresponding.rumbo.models.sampleUser +import com.appnotresponding.rumbo.navigation.AppScreens +import com.appnotresponding.rumbo.ui.components.atoms.RumboTextField +import com.appnotresponding.rumbo.ui.components.molecules.friends.UserSearchResultItem +import com.appnotresponding.rumbo.ui.components.molecules.friends.FriendRequestItem +import com.appnotresponding.rumbo.ui.components.organisms.friends.FriendsList +import com.appnotresponding.rumbo.ui.templates.FriendsTemplate +import com.appnotresponding.rumbo.ui.viewModel.ChatViewModel +import com.appnotresponding.rumbo.ui.viewModel.FriendsViewModel +import com.appnotresponding.rumbo.ui.viewModel.UserViewModel +import com.google.firebase.auth.FirebaseAuth + +@Composable +fun FriendsScreen( + controller: NavHostController, + userViewModel: UserViewModel, + friendsViewModel: FriendsViewModel, + chatViewModel: ChatViewModel +) { + val userState by userViewModel.currentUserState.collectAsState() + val currentUser = userState ?: sampleUser.copy(name = "Cargando...") + val friendsState by friendsViewModel.uiState.collectAsState() + val myUid = FirebaseAuth.getInstance().currentUser?.uid ?: "" + + var searchQuery by remember { mutableStateOf("") } + + FriendsTemplate( + currentUser = currentUser, + controller = controller + ) { + Column(modifier = Modifier.fillMaxSize()) { + RumboTextField( + modifier = Modifier.fillMaxWidth(), + value = searchQuery, + onValueChange = { + searchQuery = it + if (it.isBlank()) { + friendsViewModel.clearSearch() + } else { + friendsViewModel.searchUserByName(it) + } + }, + placeholder = "Buscar por nombre...", + label = "Buscar usuarios" + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (searchQuery.isNotBlank()) { + if (friendsState.isSearching) { + Text( + text = "Buscando...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else if (friendsState.searchError != null && friendsState.searchResults.isEmpty()) { + Text( + text = friendsState.searchError!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(bottom = 100.dp) + ) { + items(friendsState.searchResults) { user -> + UserSearchResultItem( + user = user, + isAlreadyFriend = friendsState.friendIds.contains(user.id), + isPending = friendsState.sentRequestIds.contains(user.id), + onAddClick = { friendsViewModel.addFriend(user.id) } + ) + } + } + } + } else { + if (friendsState.pendingRequests.isNotEmpty()) { + Text( + text = "Solicitudes de amistad", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp) + ) { + items(friendsState.pendingRequests) { requestUser -> + FriendRequestItem( + user = requestUser, + onAcceptClick = { friendsViewModel.acceptFriendRequest(requestUser.id) }, + onDeclineClick = { friendsViewModel.declineFriendRequest(requestUser.id) } + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + + Text( + text = "Mis amigos", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + if (friendsState.friends.isEmpty()) { + Text( + text = "Aún no tienes amigos agregados.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + FriendsList( + friends = friendsState.friends, + onFriendClick = { friend -> + val chatId = chatViewModel.getOrCreateDirectChatId(myUid, friend.id) + chatViewModel.selectDirectChat( + chatId = chatId, + chatTitle = friend.name, + photoUrl = friend.profilePictureUrl + ) + controller.navigate(AppScreens.ChatThread.name) + } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/map/MapScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/map/MapScreen.kt index fc5f2cb..f6ad0c3 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/map/MapScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/map/MapScreen.kt @@ -12,12 +12,15 @@ import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel import com.appnotresponding.rumbo.ui.viewModel.UserViewModel +import com.appnotresponding.rumbo.ui.viewModel.FriendsViewModel + @Composable fun MapScreen( controller: NavHostController, placesViewModel: PlacesViewModel, locationViewModel: UserLocationViewModel, - userViewModel: UserViewModel + userViewModel: UserViewModel, + friendsViewModel: FriendsViewModel ) { val userState by userViewModel.currentUserState.collectAsState() val user = userState ?: sampleUser.copy(name = "Cargando...") @@ -29,6 +32,7 @@ fun MapScreen( popUpTo(controller.graph.startDestinationId) { inclusive = true } launchSingleTop = true } - }, placesViewModel = placesViewModel, locationViewModel = locationViewModel + }, placesViewModel = placesViewModel, locationViewModel = locationViewModel, + userViewModel = userViewModel, friendsViewModel = friendsViewModel ) } \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt index ed8d275..2331917 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt @@ -3,6 +3,7 @@ package com.appnotresponding.rumbo.ui.templates import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable @@ -11,34 +12,57 @@ import androidx.compose.ui.unit.dp import com.appnotresponding.rumbo.models.User import com.appnotresponding.rumbo.ui.components.molecules.chat.MessageComposer import com.appnotresponding.rumbo.ui.components.organisms.common.ChatTopBar - + @Composable fun ChatThreadTemplate( modifier: Modifier = Modifier, chatTitle: String, chatSubtitle: String, chatAvatarUser: User, + isGroup: Boolean = false, + isMuted: Boolean = false, + onMuteClick: (() -> Unit)? = null, + onLeaveClick: (() -> Unit)? = null, + onBackClick: (() -> Unit)? = null, messageInputValue: String = "", onMessageInputValueChange: (String) -> Unit = {}, onSendClick: () -> Unit = {}, + onImageClick: () -> Unit = {}, + onLocationClick: () -> Unit = {}, + onMicClick: () -> Unit = {}, content: @Composable () -> Unit ) { Scaffold(contentWindowInsets = WindowInsets(0), topBar = { - ChatTopBar(u = chatAvatarUser, activity = chatSubtitle) + ChatTopBar( + u = chatAvatarUser, + activity = chatSubtitle, + isGroup = isGroup, + isMuted = isMuted, + onMuteClick = onMuteClick, + onLeaveClick = onLeaveClick, + onBackClick = onBackClick + ) }, bottomBar = { - Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { + Box( + modifier = Modifier + .navigationBarsPadding() + .padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 12.dp) + ) { MessageComposer( value = messageInputValue, onValueChange = onMessageInputValueChange, - onSendClick = onSendClick + onSendClick = onSendClick, + onImageClick = onImageClick, + onLocationClick = onLocationClick, + onMicClick = onMicClick ) } }) { paddingValues -> Box( modifier = modifier .fillMaxSize() - .padding(bottom = paddingValues.calculateBottomPadding()) - .padding(horizontal = 8.dp) + .padding(paddingValues) + .padding(horizontal = 16.dp) ) { content() } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt new file mode 100644 index 0000000..232d926 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt @@ -0,0 +1,59 @@ +package com.appnotresponding.rumbo.ui.templates + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.appnotresponding.rumbo.models.User +import com.appnotresponding.rumbo.ui.components.organisms.common.MainTopBar +import com.appnotresponding.rumbo.ui.components.organisms.common.Nav + +@Composable +fun FriendsTemplate( + currentUser: User, + onProfileClick: () -> Unit = {}, + controller: NavHostController, + content: @Composable () -> Unit +) { + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { MainTopBar(u = currentUser, onProfileClick = onProfileClick) }, + bottomBar = { Nav(controller) } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)) { + Text( + text = "Amigos", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Busca y conecta con otros viajeros", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Box( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) { + content() + } + } + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt index a6d1fa7..501365e 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt @@ -15,11 +15,17 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.IconButton +import androidx.compose.material3.Icon +import androidx.compose.ui.res.painterResource +import com.appnotresponding.rumbo.ui.viewModel.UserViewModel +import com.appnotresponding.rumbo.ui.viewModel.FriendsViewModel import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -33,6 +39,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap @@ -110,7 +117,9 @@ fun MapTemplate( viewModel: MapViewModel = viewModel(), dropNoteViewModel: DropNoteViewModel = viewModel(), placesViewModel: PlacesViewModel, - locationViewModel: UserLocationViewModel + locationViewModel: UserLocationViewModel, + userViewModel: UserViewModel, + friendsViewModel: FriendsViewModel ) { Log.d("RECOMPOSE", "MapTemplate recomposed") @@ -120,6 +129,7 @@ fun MapTemplate( val dropNoteState by dropNoteViewModel.uiState.collectAsState() val userLocationState by locationViewModel.uiState.collectAsState() val placesState by placesViewModel.uiState.collectAsState() + val friendsState by friendsViewModel.uiState.collectAsState() var popupStateDNComposer by remember { mutableStateOf(false) } var popupStateReview by remember { mutableStateOf(false) } @@ -228,6 +238,22 @@ fun MapTemplate( currentMapStyle = if (isDarkTheme) MapColorScheme.DARK else MapColorScheme.LIGHT } + LaunchedEffect(placesState.selectedPlace) { + val place = placesState.selectedPlace + if (place != null) { + userViewModel.updateActivity("Rumbo al ${place.name}") + } else { + userViewModel.updateActivity(null) + } + } + + LaunchedEffect(placesState.focusLocation) { + placesState.focusLocation?.let { latLng -> + cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 16f) + placesViewModel.clearFocusLocation() + } + } + Scaffold( contentWindowInsets = WindowInsets(0), @@ -235,7 +261,8 @@ fun MapTemplate( floatingActionButton = { Column( modifier = Modifier - .width(45.dp), + .width(56.dp) + .padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom) ) { if (permission.status.isGranted) { @@ -261,7 +288,31 @@ fun MapTemplate( locationState.requestPermission() } } - + Box( + modifier = Modifier + .aspectRatio(1f) + .clip(CircleShape) + .background( + if (user.sharingLocation) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant + ), contentAlignment = Alignment.Center + ) { + IconButton(onClick = { + Log.d("MapTemplate", "Eye button clicked! Current state sharingLocation=${user.sharingLocation}, toggling to ${!user.sharingLocation}") + userViewModel.toggleLocationSharing(!user.sharingLocation) + }) { + Icon( + painter = painterResource( + if (user.sharingLocation) R.drawable.ic_eye_open + else R.drawable.ic_eye_crossed + ), + contentDescription = "Compartir ubicación", + tint = if (user.sharingLocation) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + } } } }, @@ -303,6 +354,20 @@ fun MapTemplate( ){ Avatar(user = user, modifier = Modifier.border(1.dp, Color.White, CircleShape)) } + friendsState.friends.forEach { friend -> + if (friend.sharingLocation && (friend.latitude != 0.0 || friend.longitude != 0.0)) { + val friendPos = LatLng(friend.latitude, friend.longitude) + MarkerComposable( + state = rememberUpdatedMarkerState(friendPos), + title = "${friend.name} ${friend.lastname}" + ) { + Avatar( + user = friend, + modifier = Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape) + ) + } + } + } Marker( state = rememberUpdatedMarkerState(state.additionalMarker.position), title = state.additionalMarker.title, diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt new file mode 100644 index 0000000..920aa19 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt @@ -0,0 +1,272 @@ +package com.appnotresponding.rumbo.ui.viewModel + +import androidx.lifecycle.ViewModel +import com.appnotresponding.rumbo.models.ChatMessage +import com.appnotresponding.rumbo.models.User +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.database.DataSnapshot +import com.google.firebase.storage.FirebaseStorage +import android.net.Uri +import com.google.firebase.database.DatabaseError +import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.ValueEventListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class ChatThreadState( + val messages: List = emptyList(), + val isSending: Boolean = false, + val messageAuthors: Map = emptyMap() +) + +class ChatThreadViewModel : ViewModel() { + + private val auth = FirebaseAuth.getInstance() + private val db = FirebaseDatabase.getInstance() + private val storage = FirebaseStorage.getInstance() + + private val _uiState = MutableStateFlow(ChatThreadState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var currentListener: ValueEventListener? = null + private var currentRef: com.google.firebase.database.DatabaseReference? = null + + private val dbUsers = db.getReference("users") + private val userCache = mutableMapOf() + private val userListeners = mutableMapOf() + + private fun clearUserListeners() { + userListeners.forEach { (senderId, listener) -> + dbUsers.child(senderId).removeEventListener(listener) + } + userListeners.clear() + userCache.clear() + } + + private fun resolveUsersAndEmit(rawMessages: List, extraUid: String? = null) { + val uniqueSenderIds = (rawMessages.map { it.senderId } + listOfNotNull(extraUid)).distinct() + + fun pushState() { + val authorsMap = userCache.toMap() + _uiState.update { + it.copy(messages = rawMessages, messageAuthors = authorsMap) + } + } + + uniqueSenderIds.forEach { senderId -> + if (!userCache.containsKey(senderId) && !userListeners.containsKey(senderId)) { + val listener = object : ValueEventListener { + override fun onDataChange(userSnapshot: DataSnapshot) { + val user = userSnapshot.getValue(User::class.java) + if (user != null) { + userCache[senderId] = user + } + pushState() + } + + override fun onCancelled(error: DatabaseError) {} + } + userListeners[senderId] = listener + dbUsers.child(senderId).addValueEventListener(listener) + } + } + + pushState() + } + + fun listenToMessages(chatId: String) { + clearUserListeners() + currentRef?.let { ref -> + currentListener?.let { ref.removeEventListener(it) } + } + + val ref = db.getReference("messages").child(chatId) + currentRef = ref + + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val messages = mutableListOf() + for (child in snapshot.children) { + val msg = child.getValue(ChatMessage::class.java) ?: continue + messages.add(msg) + } + val parts = chatId.split("_") + val myUid = auth.currentUser?.uid ?: "" + val otherUid = parts.firstOrNull { it != myUid } + resolveUsersAndEmit(messages.sortedBy { m -> m.timestamp }, otherUid) + } + + override fun onCancelled(error: DatabaseError) {} + } + currentListener = listener + ref.addValueEventListener(listener) + } + + fun listenToGroupMessages(placeId: String) { + clearUserListeners() + currentRef?.let { ref -> + currentListener?.let { ref.removeEventListener(it) } + } + + val ref = db.getReference("groupMessages").child(placeId) + currentRef = ref + + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val messages = mutableListOf() + for (child in snapshot.children) { + val msg = child.getValue(ChatMessage::class.java) ?: continue + messages.add(msg) + } + resolveUsersAndEmit(messages.sortedBy { m -> m.timestamp }) + } + + override fun onCancelled(error: DatabaseError) {} + } + currentListener = listener + ref.addValueEventListener(listener) + } + + fun sendMessage(chatId: String, text: String) { + val myUid = auth.currentUser?.uid ?: return + if (text.isBlank()) return + + _uiState.update { it.copy(isSending = true) } + + val participants = chatId.split("_") + if (participants.size == 2) { + db.getReference("chats").child(chatId).child("participants").setValue(participants) + } + + val ref = db.getReference("messages").child(chatId) + val msgId = ref.push().key ?: return + val msg = ChatMessage( + id = msgId, + senderId = myUid, + text = text, + timestamp = System.currentTimeMillis() + ) + ref.child(msgId).setValue(msg).addOnSuccessListener { + db.getReference("chats").child(chatId).child("lastMessage").setValue(text) + db.getReference("chats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + _uiState.update { it.copy(isSending = false) } + }.addOnFailureListener { + _uiState.update { it.copy(isSending = false) } + } + } + + fun sendGroupMessage(placeId: String, senderName: String, text: String) { + val myUid = auth.currentUser?.uid ?: return + if (text.isBlank()) return + + _uiState.update { it.copy(isSending = true) } + val ref = db.getReference("groupMessages").child(placeId) + val msgId = ref.push().key ?: return + val msg = ChatMessage( + id = msgId, + senderId = myUid, + senderName = senderName, + text = text, + timestamp = System.currentTimeMillis() + ) + ref.child(msgId).setValue(msg).addOnSuccessListener { + db.getReference("groupChats").child(placeId).child("lastMessage").setValue("${senderName}: $text") + db.getReference("groupChats").child(placeId).child("lastMessageTimestamp").setValue(msg.timestamp) + _uiState.update { it.copy(isSending = false) } + }.addOnFailureListener { + _uiState.update { it.copy(isSending = false) } + } + } + + fun createDirectChatIfNeeded(chatId: String, myUid: String, friendUid: String) { + val ref = db.getReference("chats").child(chatId) + ref.child("participants").setValue(listOf(myUid, friendUid)) + } + + fun sendLocationMessage(chatId: String, senderName: String?, latitude: Double, longitude: Double, isGroup: Boolean) { + val myUid = auth.currentUser?.uid ?: return + _uiState.update { it.copy(isSending = true) } + + val ref = if (isGroup) db.getReference("groupMessages").child(chatId) else db.getReference("messages").child(chatId) + val msgId = ref.push().key ?: return + val textValue = "Ubicación: $latitude, $longitude" + val msg = ChatMessage( + id = msgId, + senderId = myUid, + senderName = senderName ?: "", + text = textValue, + timestamp = System.currentTimeMillis(), + type = "location" + ) + + ref.child(msgId).setValue(msg).addOnSuccessListener { + if (isGroup) { + db.getReference("groupChats").child(chatId).child("lastMessage").setValue("${senderName ?: ""}: 📍 Ubicación") + db.getReference("groupChats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + } else { + val parts = chatId.split("_") + if (parts.size == 2) { + val friendUid = if (parts[0] == myUid) parts[1] else parts[0] + createDirectChatIfNeeded(chatId, myUid, friendUid) + } + db.getReference("chats").child(chatId).child("lastMessage").setValue("📍 Ubicación") + db.getReference("chats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + } + _uiState.update { it.copy(isSending = false) } + }.addOnFailureListener { + _uiState.update { it.copy(isSending = false) } + } + } + + fun sendMediaMessage(chatId: String, senderName: String?, uri: Uri, isGroup: Boolean, mediaType: String) { + val myUid = auth.currentUser?.uid ?: return + _uiState.update { it.copy(isSending = true) } + + val storageRef = storage.reference.child("chat_media").child(chatId).child("${System.currentTimeMillis()}_${myUid}") + storageRef.putFile(uri).addOnSuccessListener { + storageRef.downloadUrl.addOnSuccessListener { downloadUrl -> + val ref = if (isGroup) db.getReference("groupMessages").child(chatId) else db.getReference("messages").child(chatId) + val msgId = ref.push().key ?: return@addOnSuccessListener + val msg = ChatMessage( + id = msgId, + senderId = myUid, + senderName = senderName ?: "", + text = if (mediaType == "image") "📷 Imagen" else "🎤 Nota de voz", + timestamp = System.currentTimeMillis(), + type = mediaType, + mediaUrl = downloadUrl.toString() + ) + + ref.child(msgId).setValue(msg).addOnSuccessListener { + if (isGroup) { + db.getReference("groupChats").child(chatId).child("lastMessage").setValue("${senderName ?: ""}: ${msg.text}") + db.getReference("groupChats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + } else { + val parts = chatId.split("_") + if (parts.size == 2) { + val friendUid = if (parts[0] == myUid) parts[1] else parts[0] + createDirectChatIfNeeded(chatId, myUid, friendUid) + } + db.getReference("chats").child(chatId).child("lastMessage").setValue(msg.text) + db.getReference("chats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + } + _uiState.update { state -> state.copy(isSending = false) } + }.addOnFailureListener { + _uiState.update { state -> state.copy(isSending = false) } + } + } + }.addOnFailureListener { + _uiState.update { it.copy(isSending = false) } + } + } + + override fun onCleared() { + super.onCleared() + clearUserListeners() + currentRef?.let { ref -> + currentListener?.let { ref.removeEventListener(it) } + } + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt new file mode 100644 index 0000000..7252b89 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt @@ -0,0 +1,298 @@ +package com.appnotresponding.rumbo.ui.viewModel + +import androidx.lifecycle.ViewModel +import com.appnotresponding.rumbo.models.ChatConversation +import com.appnotresponding.rumbo.models.GroupChat +import com.appnotresponding.rumbo.models.Place +import com.appnotresponding.rumbo.models.User +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.database.DataSnapshot +import com.google.firebase.database.DatabaseError +import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.ValueEventListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class ChatListState( + val directChats: List = emptyList(), + val groupChats: List = emptyList(), + val selectedChatId: String = "", + val selectedChatTitle: String = "", + val selectedChatPhoto: String? = null, + val isGroupChat: Boolean = false +) + +class ChatViewModel : ViewModel() { + + private val auth = FirebaseAuth.getInstance() + private val db = FirebaseDatabase.getInstance() + private val dbChats = db.getReference("chats") + private val dbUsers = db.getReference("users") + private val dbGroupChats = db.getReference("groupChats") + + private val _uiState = MutableStateFlow(ChatListState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val chatListeners = mutableListOf>() + private val groupListeners = mutableMapOf() + private var authListener: FirebaseAuth.AuthStateListener? = null + + private val userListeners = mutableMapOf() + private val resolvedUsers = mutableMapOf() + private var latestChatsSnapshot: DataSnapshot? = null + + init { + authListener = FirebaseAuth.AuthStateListener { firebaseAuth -> + val uid = firebaseAuth.currentUser?.uid + clearAllListeners() + if (uid != null) { + listenToDirectChats(uid) + } else { + _uiState.update { ChatListState() } + } + } + auth.addAuthStateListener(authListener!!) + } + + private fun setupUserListener(otherUid: String, myUid: String) { + if (userListeners.containsKey(otherUid)) return + val userListener = object : ValueEventListener { + override fun onDataChange(userSnapshot: DataSnapshot) { + val user = userSnapshot.getValue(User::class.java) + if (user != null) { + resolvedUsers[otherUid] = user + rebuildConversationsList(myUid) + } + } + override fun onCancelled(error: DatabaseError) {} + } + userListeners[otherUid] = userListener + dbUsers.child(otherUid).addValueEventListener(userListener) + } + + private fun rebuildConversationsList(myUid: String) { + val snapshot = latestChatsSnapshot ?: return + val conversations = mutableListOf() + val children = snapshot.children.toList() + + if (children.isEmpty()) { + _uiState.update { it.copy(directChats = emptyList()) } + return + } + + var pending = children.size + if (pending == 0) { + _uiState.update { it.copy(directChats = emptyList()) } + return + } + + for (child in children) { + val chatId = child.key + if (chatId == null) { + pending-- + if (pending == 0) { + _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } + } + continue + } + val participants = child.child("participants").children.map { it.value as? String ?: "" } + if (!participants.contains(myUid)) { + pending-- + if (pending == 0) { + _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } + } + continue + } + val otherUid = participants.firstOrNull { it != myUid } + if (otherUid == null) { + pending-- + if (pending == 0) { + _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } + } + continue + } + val lastMessage = child.child("lastMessage").value as? String ?: "" + val lastTimestamp = child.child("lastMessageTimestamp").value as? Long ?: 0L + + db.getReference("friendships").child(myUid).child(otherUid).get().addOnSuccessListener { friendshipSnap -> + val areFriends = friendshipSnap.exists() && friendshipSnap.value == true + if (!areFriends) { + pending-- + if (pending == 0) { + _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } + } + return@addOnSuccessListener + } + + val user = resolvedUsers[otherUid] + if (user != null) { + conversations.add( + ChatConversation( + chatId = chatId, + otherUserId = otherUid, + otherUserName = user.name, + otherUserPhotoUrl = user.profilePictureUrl, + otherUserActivity = user.activity, + lastMessage = lastMessage, + lastMessageTimestamp = lastTimestamp + ) + ) + } else { + setupUserListener(otherUid, myUid) + } + pending-- + if (pending == 0) { + _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } + } + }.addOnFailureListener { + pending-- + if (pending == 0) { + _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } + } + } + } + } + + private fun listenToDirectChats(myUid: String) { + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + latestChatsSnapshot = snapshot + rebuildConversationsList(myUid) + } + + override fun onCancelled(error: DatabaseError) {} + } + dbChats.addValueEventListener(listener) + chatListeners.add(Pair(dbChats, listener)) + } + + fun listenToGroupChats(itinerary: List) { + val myUid = auth.currentUser?.uid ?: return + val currentPlaceIds = itinerary.map { it.id }.toSet() + + val toRemove = groupListeners.keys - currentPlaceIds + for (placeId in toRemove) { + val listener = groupListeners[placeId] + if (listener != null) { + dbGroupChats.child(placeId).removeEventListener(listener) + } + groupListeners.remove(placeId) + } + + _uiState.update { state -> + state.copy(groupChats = state.groupChats.filter { it.placeId in currentPlaceIds }) + } + + for (place in itinerary) { + if (groupListeners.containsKey(place.id)) continue + + val ref = dbGroupChats.child(place.id) + ref.child("participants").child(myUid).setValue(true) + ref.child("placeId").setValue(place.id) + ref.child("placeName").setValue(place.name) + + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val placeId = snapshot.child("placeId").value as? String ?: return@onDataChange + val placeName = snapshot.child("placeName").value as? String ?: "" + val lastMessage = snapshot.child("lastMessage").value as? String ?: "" + val lastTimestamp = snapshot.child("lastMessageTimestamp").value as? Long ?: 0L + val mutedByMap = mutableMapOf() + for (muteChild in snapshot.child("mutedBy").children) { + val muteKey = muteChild.key ?: continue + mutedByMap[muteKey] = muteChild.value as? Boolean ?: false + } + + val group = GroupChat( + placeId = placeId, + placeName = placeName, + lastMessage = lastMessage, + lastMessageTimestamp = lastTimestamp, + mutedBy = mutedByMap + ) + + val current = _uiState.value.groupChats.toMutableList() + val idx = current.indexOfFirst { it.placeId == placeId } + if (idx >= 0) current[idx] = group else current.add(group) + _uiState.update { it.copy(groupChats = current.toList()) } + } + + override fun onCancelled(error: DatabaseError) {} + } + ref.addValueEventListener(listener) + groupListeners[place.id] = listener + } + } + + fun selectDirectChat(chatId: String, chatTitle: String, photoUrl: String?) { + _uiState.update { + it.copy( + selectedChatId = chatId, + selectedChatTitle = chatTitle, + selectedChatPhoto = photoUrl, + isGroupChat = false + ) + } + } + + fun selectGroupChat(placeId: String, placeName: String) { + _uiState.update { + it.copy( + selectedChatId = placeId, + selectedChatTitle = placeName, + selectedChatPhoto = null, + isGroupChat = true + ) + } + } + + fun getOrCreateDirectChatId(myUid: String, friendUid: String): String { + val sorted = listOf(myUid, friendUid).sorted() + return "${sorted[0]}_${sorted[1]}" + } + + fun leaveGroup(placeId: String) { + val myUid = auth.currentUser?.uid ?: return + dbGroupChats.child(placeId).child("participants").child(myUid).removeValue() + val current = _uiState.value.groupChats.toMutableList() + current.removeAll { it.placeId == placeId } + _uiState.update { it.copy(groupChats = current.toList()) } + } + + fun muteGroup(placeId: String) { + val myUid = auth.currentUser?.uid ?: return + dbGroupChats.child(placeId).child("mutedBy").child(myUid).setValue(true) + } + + fun unmuteGroup(placeId: String) { + val myUid = auth.currentUser?.uid ?: return + dbGroupChats.child(placeId).child("mutedBy").child(myUid).removeValue() + } + + private fun clearAllListeners() { + for ((ref, listener) in chatListeners) { + ref.removeEventListener(listener) + } + chatListeners.clear() + + for ((placeId, listener) in groupListeners) { + dbGroupChats.child(placeId).removeEventListener(listener) + } + groupListeners.clear() + + for ((otherUid, listener) in userListeners) { + dbUsers.child(otherUid).removeEventListener(listener) + } + userListeners.clear() + resolvedUsers.clear() + latestChatsSnapshot = null + } + + override fun onCleared() { + super.onCleared() + authListener?.let { auth.removeAuthStateListener(it) } + clearAllListeners() + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt new file mode 100644 index 0000000..bb688f2 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt @@ -0,0 +1,248 @@ +package com.appnotresponding.rumbo.ui.viewModel + +import androidx.lifecycle.ViewModel +import com.appnotresponding.rumbo.models.User +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.database.DataSnapshot +import com.google.firebase.database.DatabaseError +import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.ValueEventListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class FriendsState( + val friends: List = emptyList(), + val searchResults: List = emptyList(), + val isSearching: Boolean = false, + val searchError: String? = null, + val friendIds: Set = emptySet(), + val pendingRequests: List = emptyList(), + val sentRequestIds: Set = emptySet() +) + +class FriendsViewModel : ViewModel() { + + private val auth = FirebaseAuth.getInstance() + private val dbUsers = FirebaseDatabase.getInstance().getReference("users") + private val dbFriendships = FirebaseDatabase.getInstance().getReference("friendships") + private val dbRequests = FirebaseDatabase.getInstance().getReference("friend_requests") + private val dbSentRequests = FirebaseDatabase.getInstance().getReference("friend_requests_sent") + + private val _uiState = MutableStateFlow(FriendsState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var friendsListener: ValueEventListener? = null + private var requestsListener: ValueEventListener? = null + private var sentRequestsListener: ValueEventListener? = null + private var authListener: FirebaseAuth.AuthStateListener? = null + + init { + authListener = FirebaseAuth.AuthStateListener { firebaseAuth -> + val uid = firebaseAuth.currentUser?.uid + clearAllListeners() + if (uid != null) { + listenToFriends(uid) + listenToRequests(uid) + } else { + _uiState.update { FriendsState() } + } + } + auth.addAuthStateListener(authListener!!) + } + + private fun listenToFriends(myUid: String) { + friendsListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val friendIds = mutableSetOf() + for (child in snapshot.children) { + friendIds.add(child.key ?: continue) + } + _uiState.update { it.copy(friendIds = friendIds) } + loadFriendUsers(friendIds.toList()) + } + + override fun onCancelled(error: DatabaseError) {} + } + dbFriendships.child(myUid).addValueEventListener(friendsListener!!) + } + + private val friendListeners = mutableMapOf() + + private fun loadFriendUsers(friendIds: List) { + friendListeners.forEach { (friendId, listener) -> + dbUsers.child(friendId).removeEventListener(listener) + } + friendListeners.clear() + + if (friendIds.isEmpty()) { + _uiState.update { it.copy(friends = emptyList()) } + return + } + + val friendsMap = mutableMapOf() + for (friendId in friendIds) { + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val user = snapshot.getValue(User::class.java) + if (user != null) { + friendsMap[friendId] = user + _uiState.update { it.copy(friends = friendsMap.values.toList()) } + } + } + + override fun onCancelled(error: DatabaseError) {} + } + friendListeners[friendId] = listener + dbUsers.child(friendId).addValueEventListener(listener) + } + } + + private fun listenToRequests(myUid: String) { + requestsListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val senderIds = snapshot.children.mapNotNull { it.key } + loadRequestUsers(senderIds) + } + override fun onCancelled(error: DatabaseError) {} + } + dbRequests.child(myUid).addValueEventListener(requestsListener!!) + + sentRequestsListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val sentIds = snapshot.children.mapNotNull { it.key }.toSet() + _uiState.update { it.copy(sentRequestIds = sentIds) } + } + override fun onCancelled(error: DatabaseError) {} + } + dbSentRequests.child(myUid).addValueEventListener(sentRequestsListener!!) + } + + private val requestUsersMap = mutableMapOf() + private val requestListeners = mutableMapOf() + + private fun loadRequestUsers(senderIds: List) { + requestListeners.forEach { (senderId, listener) -> + dbUsers.child(senderId).removeEventListener(listener) + } + requestListeners.clear() + + if (senderIds.isEmpty()) { + requestUsersMap.clear() + _uiState.update { it.copy(pendingRequests = emptyList()) } + return + } + + requestUsersMap.keys.retainAll(senderIds) + _uiState.update { it.copy(pendingRequests = requestUsersMap.values.toList()) } + + for (senderId in senderIds) { + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val user = snapshot.getValue(User::class.java) + if (user != null) { + requestUsersMap[senderId] = user + _uiState.update { it.copy(pendingRequests = requestUsersMap.values.toList()) } + } + } + override fun onCancelled(error: DatabaseError) {} + } + requestListeners[senderId] = listener + dbUsers.child(senderId).addValueEventListener(listener) + } + } + + fun searchUserByName(query: String) { + val myUid = auth.currentUser?.uid ?: return + if (query.isBlank()) { + _uiState.update { it.copy(searchResults = emptyList(), searchError = null) } + return + } + _uiState.update { it.copy(isSearching = true, searchError = null) } + dbUsers.get().addOnSuccessListener { snapshot -> + val results = mutableListOf() + for (child in snapshot.children) { + val user = child.getValue(User::class.java) ?: continue + val fullName = "${user.name} ${user.lastname}".lowercase().trim() + if (user.id != myUid && fullName.contains(query.lowercase().trim())) { + results.add(user) + } + } + _uiState.update { + it.copy( + searchResults = results, + isSearching = false, + searchError = if (results.isEmpty()) "No se encontraron usuarios" else null + ) + } + }.addOnFailureListener { + _uiState.update { s -> s.copy(isSearching = false, searchError = "Error al buscar") } + } + } + + fun addFriend(targetUid: String) { + val myUid = auth.currentUser?.uid ?: return + if (targetUid == myUid) return + + // Optimistic UI: update sentRequestIds immediately + _uiState.update { state -> + val updatedSent = state.sentRequestIds.toMutableSet().apply { add(targetUid) } + state.copy(sentRequestIds = updatedSent) + } + + dbRequests.child(targetUid).child(myUid).setValue(true) + dbSentRequests.child(myUid).child(targetUid).setValue(true) + } + + fun acceptFriendRequest(senderUid: String) { + val myUid = auth.currentUser?.uid ?: return + + // 1. Remove request + dbRequests.child(myUid).child(senderUid).removeValue() + dbSentRequests.child(senderUid).child(myUid).removeValue() + + // 2. Add mutual friendship + dbFriendships.child(myUid).child(senderUid).setValue(true) + dbFriendships.child(senderUid).child(myUid).setValue(true) + } + + fun declineFriendRequest(senderUid: String) { + val myUid = auth.currentUser?.uid ?: return + + // Remove request + dbRequests.child(myUid).child(senderUid).removeValue() + dbSentRequests.child(senderUid).child(myUid).removeValue() + } + + fun clearSearch() { + _uiState.update { it.copy(searchResults = emptyList(), searchError = null) } + } + + private fun clearAllListeners() { + val uid = auth.currentUser?.uid + if (uid != null) { + friendsListener?.let { dbFriendships.child(uid).removeEventListener(it) } + requestsListener?.let { dbRequests.child(uid).removeEventListener(it) } + sentRequestsListener?.let { dbSentRequests.child(uid).removeEventListener(it) } + } + friendListeners.forEach { (friendId, listener) -> + dbUsers.child(friendId).removeEventListener(listener) + } + friendListeners.clear() + requestListeners.forEach { (senderId, listener) -> + dbUsers.child(senderId).removeEventListener(listener) + } + requestListeners.clear() + + friendsListener = null + requestsListener = null + sentRequestsListener = null + } + + override fun onCleared() { + super.onCleared() + authListener?.let { auth.removeAuthStateListener(it) } + clearAllListeners() + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt index c7742b9..b49c485 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt @@ -37,4 +37,12 @@ class PlacesViewModel : ViewModel() { fun clearForNavigation() { _uiState.update { it.copy(selectedPlace = null) } } + + fun focusOnLocation(latLng: com.google.android.gms.maps.model.LatLng) { + _uiState.update { it.copy(focusLocation = latLng) } + } + + fun clearFocusLocation() { + _uiState.update { it.copy(focusLocation = null) } + } } \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt index eac0223..b89db5c 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt @@ -31,14 +31,54 @@ class UserViewModel : ViewModel() { } private fun fetchUserData(uid: String) { + android.util.Log.d("UserViewModel", "Starting ValueEventListener for uid: $uid") dbRef.child(uid).addValueEventListener(object : ValueEventListener { override fun onDataChange(snapshot: DataSnapshot) { - val user = snapshot.getValue(User::class.java) - _currentUserState.value = user + try { + val user = snapshot.getValue(User::class.java) + android.util.Log.d("UserViewModel", "fetchUserData success: user=${user?.name}, sharingLocation=${user?.sharingLocation}") + _currentUserState.value = user + } catch (e: Exception) { + android.util.Log.e("UserViewModel", "Error deserializing User object: ${e.message}", e) + } } override fun onCancelled(error: DatabaseError) { + android.util.Log.e("UserViewModel", "fetchUserData cancelled: ${error.message}") } }) } + + fun toggleLocationSharing(isSharing: Boolean) { + val uid = auth.currentUser?.uid + if (uid == null) { + android.util.Log.e("UserViewModel", "Cannot toggle location sharing: user not authenticated (uid is null)") + return + } + android.util.Log.d("UserViewModel", "Toggling location sharing to $isSharing for uid: $uid") + dbRef.child(uid).child("sharingLocation").setValue(isSharing) + .addOnSuccessListener { + android.util.Log.d("UserViewModel", "Location sharing successfully set to $isSharing in DB") + } + .addOnFailureListener { e -> + android.util.Log.e("UserViewModel", "Failed to set location sharing to $isSharing: ${e.message}", e) + } + } + + fun updateActivity(activity: String?) { + val uid = auth.currentUser?.uid + if (uid == null) { + android.util.Log.e("UserViewModel", "Cannot update activity: user not authenticated (uid is null)") + return + } + android.util.Log.d("UserViewModel", "Updating activity to $activity for uid: $uid") + dbRef.child(uid).child("activity").setValue(activity) + .addOnSuccessListener { + android.util.Log.d("UserViewModel", "Activity successfully set to $activity in DB") + } + .addOnFailureListener { e -> + android.util.Log.e("UserViewModel", "Failed to set activity to $activity: ${e.message}", e) + } + } } + From 76493238aa4daf367898017d6d228791ce7b0e00 Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Tue, 2 Jun 2026 12:28:08 -0500 Subject: [PATCH 2/2] [Feat]: Improve UI/UX and Contact Disctovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esta actualización introduce funcionalidades clave para una experiencia de chat más completa y una gestión de amigos mejorada: * **Presencia en tiempo real**: Implementa estado online/offline y última conexión para usuarios, visible en chats y lista de conversaciones. * **Recibos de lectura**: Muestra "Visto" en mensajes directos y "Visto por X" en grupos, junto con indicadores de mensajes no leídos. * **Funcionalidades de chat enriquecidas**: * Captura y envío de fotos directamente desde la cámara. * Interfaz mejorada para grabar y enviar notas de voz, incluyendo un estado de grabación. * Previsualización de imágenes a pantalla completa en la conversación. * Generación de previsualizaciones de mapas para ubicaciones compartidas. * Diseño de burbujas de chat actualizado con mejor diferenciación visual y gestión de secuencias de mensajes. * Separador "Nuevos mensajes" para destacar contenido no leído. * **Descubrimiento de amigos por contactos**: Permite buscar y añadir amigos de la agenda de contactos del dispositivo. * **Actualizaciones de permisos**: Añade permisos `CAMERA` y `READ_CONTACTS`. * **Refinamientos de UI/UX**: Diversos ajustes en la interfaz de usuario, incluyendo iconos y espaciado, para una apariencia más pulida. --- .gitignore | 2 + app/src/main/AndroidManifest.xml | 4 +- .../rumbo/models/chatConversation.kt | 5 +- .../rumbo/models/chatMessage.kt | 3 +- .../com/appnotresponding/rumbo/models/user.kt | 6 +- .../ui/components/atoms/UserProfileBubble.kt | 4 +- .../components/molecules/chat/ChatBubble.kt | 157 +++++++++++++----- .../components/molecules/chat/ChatListItem.kt | 52 ++++-- .../molecules/chat/MessageComposer.kt | 135 ++++++++++----- .../molecules/friends/FriendRequestItem.kt | 2 +- .../molecules/friends/UserSearchResultItem.kt | 24 ++- .../molecules/map/MapFloatingActions.kt | 2 +- .../ui/components/organisms/chat/ChatList.kt | 7 +- .../ui/components/organisms/common/Nav.kt | 83 ++++++++- .../ui/components/organisms/common/TopBar.kt | 11 +- .../organisms/map/DropNoteComposer.kt | 2 +- .../rumbo/ui/screens/auth/SignUpScreen.kt | 4 +- .../rumbo/ui/screens/chat/ChatListScreen.kt | 16 +- .../rumbo/ui/screens/chat/ChatThreadScreen.kt | 126 +++++++++++++- .../rumbo/ui/screens/friends/FriendsScreen.kt | 91 +++++++++- .../rumbo/ui/templates/ChatThreadTemplate.kt | 8 +- .../rumbo/ui/viewModel/chatThreadViewModel.kt | 99 ++++++++++- .../rumbo/ui/viewModel/chatViewModel.kt | 16 +- .../rumbo/ui/viewModel/friendsViewModel.kt | 44 +++++ .../rumbo/ui/viewModel/userViewModel.kt | 33 +++- app/src/main/res/drawable/ic_camera.xml | 12 ++ app/src/main/res/drawable/ic_cancel.xml | 12 ++ app/src/main/res/drawable/ic_minus.xml | 13 +- app/src/main/res/drawable/ic_recording.xml | 9 + .../main/res/drawable/outline_cancel_24.xml | 5 - 30 files changed, 826 insertions(+), 161 deletions(-) create mode 100644 app/src/main/res/drawable/ic_camera.xml create mode 100644 app/src/main/res/drawable/ic_cancel.xml create mode 100644 app/src/main/res/drawable/ic_recording.xml delete mode 100644 app/src/main/res/drawable/outline_cancel_24.xml diff --git a/.gitignore b/.gitignore index e5cbb64..80e26d7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ google-services.json # Android Profiling *.hprof + +.gradle-sandbox/ \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 593be21..0ace41c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + - \ No newline at end of file + diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt b/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt index 20b2a50..9b18473 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt @@ -6,8 +6,10 @@ data class ChatConversation( val otherUserName: String = "", val otherUserPhotoUrl: String? = null, val otherUserActivity: String? = null, + val isOtherUserOnline: Boolean = false, val lastMessage: String = "", - val lastMessageTimestamp: Long = 0 + val lastMessageTimestamp: Long = 0, + val unreadCount: Int = 0 ) data class GroupChat( @@ -15,5 +17,6 @@ data class GroupChat( val placeName: String = "", val lastMessage: String = "", val lastMessageTimestamp: Long = 0, + val unreadCount: Int = 0, val mutedBy: Map = emptyMap() ) diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt b/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt index fdf4914..9086cc3 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt @@ -8,5 +8,6 @@ data class ChatMessage( val timestamp: Long = 0, val type: String = "text", val placeId: String? = null, - val mediaUrl: String? = null + val mediaUrl: String? = null, + val seenBy: Map = emptyMap() ) diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/user.kt b/app/src/main/java/com/appnotresponding/rumbo/models/user.kt index 5b5c671..99c6817 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/user.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/user.kt @@ -12,7 +12,9 @@ data class User( val altitude: Double = 0.0, val profilePictureUrl: String? = null, val sharingLocation: Boolean = false, - val activity: String? = null + val activity: String? = null, + val isOnline: Boolean = false, + val lastSeenAt: Long = 0 ) val sampleUser = User( @@ -26,4 +28,4 @@ val sampleUser = User( altitude = 0.0, profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg", sharingLocation = false -) \ No newline at end of file +) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt index ae5b1f5..7f126af 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -33,6 +34,7 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.allowHardware +import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.models.User import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.ui.theme.RumboTheme @@ -95,7 +97,7 @@ fun UserProfileBubble( if (!user.profilePictureUrl.isNullOrEmpty() && imageLoadFailed) { Icon( modifier = Modifier.size(bubbleSize * 0.5f), - imageVector = Icons.Rounded.Person, + painter = painterResource(R.drawable.ic_user), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt index ad69ace..7a0b374 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt @@ -7,12 +7,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -27,7 +30,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight @@ -36,7 +38,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.compose.SubcomposeAsyncImage -import coil3.request.ImageRequest +import com.appnotresponding.rumbo.BuildConfig import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.models.Place import com.appnotresponding.rumbo.models.samplePlace @@ -46,6 +48,9 @@ import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle import com.appnotresponding.rumbo.ui.theme.RumboTheme import android.media.MediaPlayer import androidx.compose.ui.unit.sp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale @Composable fun ChatSeparator(text: String) { @@ -92,6 +97,7 @@ enum class ChatBubbleType { */ @Composable fun ChatBubble( + modifier: Modifier = Modifier, message: String, mediaUrl: String? = null, mediaType: String? = null, @@ -100,6 +106,10 @@ fun ChatBubble( senderActivity: String? = null, type: ChatBubbleType = ChatBubbleType.Regular, place: Place? = null, + timestamp: Long = 0, + seenText: String? = null, + isLastInSequence: Boolean = true, + onMediaClick: ((String) -> Unit)? = null, onLocationClick: (() -> Unit)? = null ) { val horizontalAlignment = if (isUserMessage) { @@ -109,15 +119,15 @@ fun ChatBubble( } val backgroundColor = if (isUserMessage) { - MaterialTheme.colorScheme.secondary + MaterialTheme.colorScheme.secondaryContainer } else { - MaterialTheme.colorScheme.primary + MaterialTheme.colorScheme.surfaceContainerHighest } val contentColor = if (isUserMessage) { - MaterialTheme.colorScheme.onSecondary + MaterialTheme.colorScheme.onSecondaryContainer } else { - MaterialTheme.colorScheme.onPrimary + MaterialTheme.colorScheme.onSurface } val bubbleAlignment = if (isUserMessage) { @@ -126,33 +136,31 @@ fun ChatBubble( Arrangement.Start } - val bubbleShape = if (isUserMessage) { - RoundedCornerShape( + val bubbleShape = when { + isUserMessage && isLastInSequence -> RoundedCornerShape( topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 4.dp ) - } else { - RoundedCornerShape( + !isUserMessage && isLastInSequence -> RoundedCornerShape( topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 16.dp ) + else -> RoundedCornerShape(16.dp) } when (type) { ChatBubbleType.Regular -> { Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment + modifier = modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment ) { Column( modifier = Modifier .widthIn(max = 280.dp) - .then( - if (mediaUrl != null) Modifier.width(240.dp) else Modifier - ) + .then(if (mediaUrl != null) Modifier.widthIn(min = 220.dp, max = 280.dp) else Modifier) .background(backgroundColor, bubbleShape), horizontalAlignment = horizontalAlignment, verticalArrangement = Arrangement.spacedBy(4.dp), @@ -161,7 +169,7 @@ fun ChatBubble( Column( modifier = Modifier .then(if (mediaUrl != null) Modifier.fillMaxWidth() else Modifier) - .padding(horizontal = 16.dp, vertical = 10.dp), + .padding(16.dp), horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -194,7 +202,12 @@ fun ChatBubble( if (mediaUrl != null && mediaType == "image") { AsyncImage( - modifier = Modifier.clip(MaterialTheme.shapes.medium).fillMaxWidth(), + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .fillMaxWidth() + .clickable(enabled = onMediaClick != null) { + onMediaClick?.invoke(mediaUrl) + }, model = mediaUrl, contentScale = ContentScale.FillWidth, contentDescription = null @@ -212,8 +225,6 @@ fun ChatBubble( Row( modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium) .clickable { try { if (isPlaying) { @@ -245,16 +256,43 @@ fun ChatBubble( isPlaying = false } } - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - val icon = if (isPreparing) "⏳" else if (isPlaying) "⏸" else "▶" - Text(icon, color = MaterialTheme.colorScheme.primary) + Box( + modifier = Modifier + .size(34.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), + contentAlignment = Alignment.Center + ) { + val icon = if (isPreparing) "..." else if (isPlaying) "II" else "▶" + Text( + text = icon, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimary, + textAlign = TextAlign.Center + ) + } + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val bars = if (isPlaying) listOf(10, 18, 13, 22, 15, 24, 12, 19, 14, 20, 10, 18, 13, 22, 15) else listOf(8, 14, 10, 16, 11, 18, 9, 15, 10, 13, 8, 14, 10, 16, 11) + bars.forEach { barHeight -> + Box( + modifier = Modifier + .weight(1f) + .height(barHeight.dp) + .background(contentColor.copy(alpha = 0.55f), RoundedCornerShape(8.dp)) + ) + } + } Text( - text = if (isPreparing) "Preparando..." else if (isPlaying) "Reproduciendo..." else "Nota de voz", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = if (isPreparing) "..." else "0:00", + style = MaterialTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.7f) ) } } @@ -269,6 +307,30 @@ fun ChatBubble( color = contentColor ) } + + if (timestamp > 0 || seenText != null) { + Row( + modifier = Modifier.align(Alignment.End), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + if (timestamp > 0) { + Text( + text = formatMessageTime(timestamp), + style = MaterialTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.62f) + ) + } + if (seenText != null) { + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = seenText, + style = MaterialTheme.typography.labelSmall, + color = if (seenText == "Visto") MaterialTheme.colorScheme.primary else contentColor.copy(alpha = 0.62f) + ) + } + } + } } } } @@ -277,7 +339,7 @@ fun ChatBubble( ChatBubbleType.Location -> { Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment + modifier = modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment ) { Column( modifier = Modifier @@ -303,22 +365,27 @@ fun ChatBubble( ) } - Image( + AsyncImage( modifier = Modifier .fillMaxWidth() - .aspectRatio(3f / 2f) - .clip( - RoundedCornerShape( - bottomStart = 28.dp, - bottomEnd = 28.dp, - topStart = 0.dp, - topEnd = 0.dp - ) - ), - painter = painterResource(R.mipmap.img_map), + .aspectRatio(3f / 2f), + + model = staticMapPreviewUrl(message), + fallback = painterResource(R.mipmap.img_map), + error = painterResource(R.mipmap.img_map), contentScale = ContentScale.Crop, contentDescription = "Mapa de ubicación compartida" ) + if (timestamp > 0) { + Text( + text = formatMessageTime(timestamp), + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.7f) + ) + } } } } @@ -326,7 +393,7 @@ fun ChatBubble( ChatBubbleType.LiveActivity -> { if (place != null) { Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment + modifier = modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment ) { Column( modifier = Modifier @@ -429,12 +496,20 @@ fun ChatBubble( } } +private fun formatMessageTime(timestamp: Long): String { + return SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(timestamp)) +} + +private fun staticMapPreviewUrl(message: String): String { + val parts = message.removePrefix("Ubicación: ").split(",") + val lat = parts.getOrNull(0)?.trim()?.toDoubleOrNull() ?: 4.627293 + val lng = parts.getOrNull(1)?.trim()?.toDoubleOrNull() ?: -74.063228 + return "https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=17&size=640x360&scale=2&markers=color:red%7C$lat,$lng&key=${BuildConfig.MAPS_API_KEY}" +} + @Composable private fun ChatBubblePreviewContent() { - val context = LocalContext.current - val placeholderImage = ImageRequest.Builder(context).data(R.mipmap.img_mock).build() - Column( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatListItem.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatListItem.kt index 691c8e1..52393f2 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatListItem.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatListItem.kt @@ -41,7 +41,9 @@ fun ChatListItem( lastMessage: String, status: String? = null, timestamp: String, - hasUnread: Boolean = false + hasUnread: Boolean = false, + unreadCount: Int = 0, + isOnline: Boolean = false ) { Box( modifier = modifier @@ -63,7 +65,7 @@ fun ChatListItem( verticalAlignment = Alignment.CenterVertically ) { Box { - Avatar(user = user) + Avatar(user = user, isOnline = isOnline) } Column(modifier = Modifier.weight(1f)) { @@ -101,20 +103,40 @@ fun ChatListItem( modifier = Modifier.weight(1f) ) - if (timestamp.isNotEmpty()) { - Text( - text = timestamp, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else if (hasUnread) { - Box( - modifier = Modifier - .size(8.dp) - .background( - color = MaterialTheme.colorScheme.onSurface, shape = CircleShape + Column(horizontalAlignment = Alignment.End) { + if (timestamp.isNotEmpty()) { + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = if (unreadCount > 0 || hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (unreadCount > 0) { + Box( + modifier = Modifier + .padding(top = 4.dp) + .size(22.dp) + .background( + color = MaterialTheme.colorScheme.primary, shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = if (unreadCount > 99) "99+" else unreadCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary ) - ) + } + } else if (hasUnread) { + Box( + modifier = Modifier + .padding(top = 4.dp) + .size(8.dp) + .background( + color = MaterialTheme.colorScheme.primary, shape = CircleShape + ) + ) + } } } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt index 004fca5..f77dfe8 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt @@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.FiberManualRecord import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -45,8 +48,10 @@ fun MessageComposer( onValueChange: (String) -> Unit = {}, onSendClick: () -> Unit = {}, onImageClick: () -> Unit = {}, + onCameraClick: () -> Unit = {}, onLocationClick: () -> Unit = {}, - onMicClick: () -> Unit = {} + onMicClick: () -> Unit = {}, + isRecordingAudio: Boolean = false ) { Surface( modifier = modifier.fillMaxWidth(), @@ -57,28 +62,54 @@ fun MessageComposer( Column( modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - // Text input area - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp), - textStyle = MaterialTheme.typography.bodyLarge.copy( - color = MaterialTheme.colorScheme.onSurface - ), - decorationBox = { innerTextField -> - Box { - if (value.isEmpty()) { - Text( - text = "Mensaje", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) + if (isRecordingAudio) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_recording), + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.error + ) + Text( + text = "Grabando audio", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Toca el micrófono para enviar", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + decorationBox = { innerTextField -> + Box { + if (value.isEmpty()) { + Text( + text = "Mensaje", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + innerTextField() } - innerTextField() - } - }) + }) + } // Bottom row: action icons on the left, send button on the right Row( @@ -99,38 +130,58 @@ fun MessageComposer( tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - IconButton(onClick = onLocationClick, modifier = Modifier.size(40.dp)) { + IconButton(onClick = onCameraClick, modifier = Modifier.size(40.dp)) { Icon( - painter = painterResource(id = R.drawable.ic_marker), - contentDescription = "Compartir ubicación", + painter = painterResource(id = R.drawable.ic_camera), + contentDescription = "Tomar foto", modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - IconButton(onClick = onMicClick, modifier = Modifier.size(40.dp)) { + IconButton(onClick = onLocationClick, modifier = Modifier.size(40.dp)) { Icon( - painter = painterResource(id = R.drawable.ic_microphone), - contentDescription = "Grabar audio", + painter = painterResource(id = R.drawable.ic_marker), + contentDescription = "Compartir ubicación", modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) } + IconButton(onClick = onMicClick, modifier = Modifier.size(40.dp)) { + Box(contentAlignment = Alignment.Center) { + if (isRecordingAudio) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.error.copy(alpha = 0.16f)) + ) + } + Icon( + painter = painterResource(id = R.drawable.ic_microphone), + contentDescription = if (isRecordingAudio) "Detener grabación" else "Grabar audio", + modifier = Modifier.size(22.dp), + tint = if (isRecordingAudio) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } // Send button - IconButton( - onClick = onSendClick, - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.secondary) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_send), - contentDescription = "Enviar mensaje", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSecondary - ) + if (!isRecordingAudio) { + IconButton( + onClick = onSendClick, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondary) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_send), + contentDescription = "Enviar mensaje", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSecondary + ) + } } } } @@ -155,4 +206,4 @@ private fun MessageComposerDarkPreview() { MessageComposer() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt index 0e5b70c..2e2c295 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt @@ -77,7 +77,7 @@ fun FriendRequestItem( onClick = onDeclineClick, style = RumboButtonStyle.Secondary, size = RumboButtonSize.Small, - icon = painterResource(R.drawable.outline_cancel_24) + icon = painterResource(R.drawable.ic_cancel) ) } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt index 0c137a7..a54aeab 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt @@ -2,12 +2,16 @@ package com.appnotresponding.rumbo.ui.components.molecules.friends import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -81,13 +85,19 @@ fun UserSearchResultItem( icon = painterResource(R.drawable.ic_user) ) } else { - RumboButton( - text = "Agregar", - onClick = onAddClick, - style = RumboButtonStyle.Primary, - size = RumboButtonSize.Small, - icon = painterResource(R.drawable.ic_user_add) - ) + Box( + modifier = Modifier + .size(40.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape) + .clickable(onClick = onAddClick), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_user_add), + contentDescription = "Agregar amigo", + tint = MaterialTheme.colorScheme.onPrimary + ) + } } } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt index ed0c9cb..fb17da8 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt @@ -79,7 +79,7 @@ fun CancelRoute(onClick: () -> Unit = {}) { ) { IconButton(onClick = onClick) { Icon( - painter = painterResource(R.drawable.outline_cancel_24), + painter = painterResource(R.drawable.ic_cancel), contentDescription = "Cancel Route", tint = MaterialTheme.colorScheme.onPrimary, ) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatList.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatList.kt index 7ff2493..92bde82 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatList.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatList.kt @@ -32,6 +32,8 @@ fun ChatList( lastMessage = chat.lastMessage, status = chat.status, timestamp = chat.timestamp, + unreadCount = chat.unreadCount, + hasUnread = chat.hasUnread, modifier = Modifier.clickable { onChatClick(chat) }) } } @@ -42,7 +44,8 @@ data class ChatPreviewData( val lastMessage: String, val status: String? = null, val timestamp: String, - val hasUnread: Boolean = false + val hasUnread: Boolean = false, + val unreadCount: Int = 0 ) private val mockChats = listOf( @@ -89,4 +92,4 @@ private fun AuthPrimaryCTALightPreview() { RumboTheme(darkTheme = false) { ChatList(chatItems = mockChats) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt index cf38d8d..4b027fb 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt @@ -7,14 +7,20 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -28,6 +34,11 @@ import androidx.navigation.compose.rememberNavController import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.navigation.AppScreens import com.appnotresponding.rumbo.ui.theme.RumboTheme +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.database.DataSnapshot +import com.google.firebase.database.DatabaseError +import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.ValueEventListener enum class NavItem { Map, Chat, Plan, Itinerary @@ -43,6 +54,7 @@ enum class NavItem { fun Nav( controller: NavController ) { + var unreadCount by remember { mutableIntStateOf(0) } val navBackStackEntry by controller.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route val activeItem = when (currentRoute) { @@ -52,6 +64,50 @@ fun Nav( AppScreens.Itinerary.name -> NavItem.Itinerary else -> NavItem.Map } + + DisposableEffect(Unit) { + val uid = FirebaseAuth.getInstance().currentUser?.uid + if (uid == null) { + onDispose {} + } else { + val db = FirebaseDatabase.getInstance() + val directRef = db.getReference("chats") + val groupRef = db.getReference("groupChats") + var directUnread = 0 + var groupUnread = 0 + + fun readUnread(snapshot: DataSnapshot): Int { + return snapshot.children.sumOf { child -> + child.child("unreadCounts").child(uid).getValue(Int::class.java) + ?: child.child("unreadCounts").child(uid).getValue(Long::class.java)?.toInt() + ?: 0 + } + } + + val directListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + directUnread = readUnread(snapshot) + unreadCount = directUnread + groupUnread + } + + override fun onCancelled(error: DatabaseError) {} + } + val groupListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + groupUnread = readUnread(snapshot) + unreadCount = directUnread + groupUnread + } + + override fun onCancelled(error: DatabaseError) {} + } + directRef.addValueEventListener(directListener) + groupRef.addValueEventListener(groupListener) + onDispose { + directRef.removeEventListener(directListener) + groupRef.removeEventListener(groupListener) + } + } + } Box { Box( modifier = Modifier @@ -118,11 +174,28 @@ fun Nav( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Icon( - painter = painterResource(R.drawable.ic_messages), - contentDescription = "Chat", - tint = if (activeItem == NavItem.Chat) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - ) + Box { + Icon( + painter = painterResource(R.drawable.ic_messages), + contentDescription = "Chat", + tint = if (activeItem == NavItem.Chat) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + if (unreadCount > 0) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .size(18.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), + contentAlignment = Alignment.Center + ) { + Text( + text = if (unreadCount > 9) "9+" else unreadCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + } Text( text = "Chat", color = if (activeItem == NavItem.Chat) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt index 2ac68f4..f72e9f9 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt @@ -48,8 +48,7 @@ fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) { Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp) - .padding(top = 32.dp), + .padding(start = 16.dp, top = 32.dp, end = 16.dp, bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -90,6 +89,7 @@ fun ChatTopBar( activity: String? = null, isGroup: Boolean = false, isMuted: Boolean = false, + isOnline: Boolean = false, onMuteClick: (() -> Unit)? = null, onLeaveClick: (() -> Unit)? = null, onBackClick: (() -> Unit)? = null @@ -102,8 +102,7 @@ fun ChatTopBar( Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp) - .padding(top = 32.dp), + .padding(start = 16.dp, top = 32.dp, end = 16.dp, bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -111,13 +110,13 @@ fun ChatTopBar( if (onBackClick != null) { IconButton(onClick = onBackClick) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + painter = painterResource(R.drawable.ic_arrow_left), contentDescription = "Atrás", tint = MaterialTheme.colorScheme.onSurface ) } } - Avatar(user = u) + Avatar(user = u, isOnline = isOnline) Column { Text( text = displayName, diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/DropNoteComposer.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/DropNoteComposer.kt index f5d009b..017ed1f 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/DropNoteComposer.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/DropNoteComposer.kt @@ -99,7 +99,7 @@ fun DropNoteComposer( // Botón cámara IconButton(onClick = onImageClick, modifier = Modifier.size(40.dp)) { Icon( - painter = painterResource(id = R.drawable.ic_picture), + painter = painterResource(id = R.drawable.ic_camera), contentDescription = "Cámara", modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt index b2eb611..2dcc08a 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt @@ -35,12 +35,14 @@ import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController import coil3.compose.AsyncImage +import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.models.RegisterState import com.appnotresponding.rumbo.models.RegisterViewModel import com.appnotresponding.rumbo.navigation.AppScreens @@ -150,7 +152,7 @@ fun SignUpForm( ) } else { Icon( - imageVector = Icons.Rounded.AddAPhoto, + painter = painterResource(R.drawable.ic_add_image), contentDescription = "Seleccionar foto", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(32.dp) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt index 1ca3ba2..602d809 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -72,7 +73,8 @@ fun ChatListScreen( floatingActionButton = { FloatingActionButton( onClick = { controller.navigate(AppScreens.Friends.name) }, - containerColor = MaterialTheme.colorScheme.primary + containerColor = MaterialTheme.colorScheme.primary, + shape = CircleShape ) { Icon( painter = painterResource(R.drawable.ic_user_add), @@ -130,7 +132,8 @@ fun ChatListScreen( chatViewModel.selectDirectChat( chatId = convo.chatId, chatTitle = convo.otherUserName, - photoUrl = convo.otherUserPhotoUrl + photoUrl = convo.otherUserPhotoUrl, + isOnline = convo.isOtherUserOnline ) controller.navigate(AppScreens.ChatThread.name) }, @@ -138,7 +141,9 @@ fun ChatListScreen( lastMessage = convo.lastMessage, status = convo.otherUserActivity, timestamp = formatTimestamp(convo.lastMessageTimestamp), - hasUnread = false + hasUnread = convo.unreadCount > 0, + unreadCount = convo.unreadCount, + isOnline = convo.isOtherUserOnline ) } } @@ -170,7 +175,8 @@ fun ChatListScreen( lastMessage = if (isMuted) "🔇 Silenciado" else group.lastMessage, status = "Grupo", timestamp = formatTimestamp(group.lastMessageTimestamp), - hasUnread = false + hasUnread = group.unreadCount > 0, + unreadCount = group.unreadCount ) } } @@ -201,4 +207,4 @@ private fun formatTimestamp(timestamp: Long): String { diff < 86_400_000 -> "${diff / 3_600_000}h" else -> "${diff / 86_400_000}d" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt index 83e0ff1..076b781 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt @@ -1,11 +1,10 @@ package com.appnotresponding.rumbo.ui.screens.chat -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -18,12 +17,23 @@ import android.net.Uri import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil3.compose.AsyncImage import java.io.File import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -61,10 +71,13 @@ fun ChatThreadScreen( val chatId = chatState.selectedChatId val isGroup = chatState.isGroupChat + var unreadDividerTimestamp by remember(chatId) { mutableStateOf(null) } var mediaRecorder by remember { mutableStateOf(null) } var audioFile by remember { mutableStateOf(null) } var isRecording by remember { mutableStateOf(false) } + var pendingCameraUri by remember { mutableStateOf(null) } + var imagePreviewUrl by remember { mutableStateOf(null) } val context = LocalContext.current @@ -76,12 +89,31 @@ fun ChatThreadScreen( } } - val permissionLauncher = rememberLauncherForActivityResult( + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + val uri = pendingCameraUri + if (success && uri != null) { + chatThreadViewModel.sendMediaMessage(chatId, currentUser.name, uri, isGroup, "image") + } + pendingCameraUri = null + } + + val audioPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { isGranted -> // Handle permission result if needed } + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + pendingCameraUri = createCameraImageUri(context) + pendingCameraUri?.let { cameraLauncher.launch(it) } + } + } + LaunchedEffect(chatId) { if (chatId.isNotBlank()) { if (isGroup) { @@ -94,7 +126,11 @@ fun ChatThreadScreen( LaunchedEffect(threadState.messages.size) { if (threadState.messages.isNotEmpty()) { + if (unreadDividerTimestamp == null) { + unreadDividerTimestamp = threadState.lastReadTimestamp + } listState.animateScrollToItem(threadState.messages.size - 1) + chatThreadViewModel.markChatAsRead(chatId, isGroup) } } @@ -114,6 +150,7 @@ fun ChatThreadScreen( chatAvatarUser = avatarUser, isGroup = isGroup, isMuted = isMuted, + isOnline = !isGroup && (otherUser?.isOnline ?: chatState.selectedChatIsOnline), onMuteClick = { if (isMuted) { chatViewModel.unmuteGroup(chatId) @@ -132,7 +169,7 @@ fun ChatThreadScreen( onMessageInputValueChange = { messageInput = it }, onSendClick = { val text = messageInput.trim() - if (text.isNotBlank()) { + if (!isRecording && text.isNotBlank()) { if (isGroup) { chatThreadViewModel.sendGroupMessage(chatId, currentUser.name, text) } else { @@ -144,6 +181,14 @@ fun ChatThreadScreen( onImageClick = { imagePickerLauncher.launch("image/*") }, + onCameraClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + pendingCameraUri = createCameraImageUri(context) + pendingCameraUri?.let { cameraLauncher.launch(it) } + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, onLocationClick = { val lat = locationState.latitude val lng = locationState.longitude @@ -162,6 +207,7 @@ fun ChatThreadScreen( chatThreadViewModel.sendMediaMessage(chatId, currentUser.name, Uri.fromFile(file), isGroup, "audio") } } else { + messageInput = "" audioFile = File.createTempFile("audio", ".mp4", context.cacheDir) val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { MediaRecorder(context) @@ -183,9 +229,10 @@ fun ChatThreadScreen( } } } else { - permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } - } + }, + isRecordingAudio = isRecording ) { if (threadState.messages.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -198,11 +245,23 @@ fun ChatThreadScreen( } else { LazyColumn( state = listState, - contentPadding = PaddingValues(top = 16.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + contentPadding = PaddingValues(top = 16.dp, bottom = 16.dp) ) { - items(threadState.messages) { msg -> + itemsIndexed(threadState.messages) { index, msg -> val isMine = msg.senderId == currentUser.id + val previousMessage = threadState.messages.getOrNull(index - 1) + val nextMessage = threadState.messages.getOrNull(index + 1) + val isSameAsPrevious = previousMessage?.senderId == msg.senderId + val isLastInSequence = nextMessage?.senderId != msg.senderId + val dividerTimestamp = unreadDividerTimestamp ?: threadState.lastReadTimestamp + val shouldShowNewMessages = !isMine && + msg.timestamp > dividerTimestamp && + threadState.messages.take(index).none { previous -> + previous.senderId != currentUser.id && previous.timestamp > dividerTimestamp + } + if (shouldShowNewMessages) { + com.appnotresponding.rumbo.ui.components.molecules.chat.ChatSeparator("Nuevos mensajes") + } val author = threadState.messageAuthors[msg.senderId] val activity = author?.activity val bubbleType = if (msg.type == "location") ChatBubbleType.Location else ChatBubbleType.Regular @@ -221,7 +280,19 @@ fun ChatThreadScreen( } else null val displayName = author?.name?.takeIf { it.isNotBlank() } ?: msg.senderName + val seenText = if (isMine) { + if (isGroup) { + val seenCount = msg.seenBy.keys.count { it != currentUser.id } + if (seenCount > 0) "Visto por $seenCount" else "Enviado" + } else { + val otherHasSeen = otherUid != null && msg.seenBy[otherUid] == true + if (otherHasSeen) "Visto" else "Enviado" + } + } else { + null + } ChatBubble( + modifier = Modifier.padding(top = if (isSameAsPrevious) 2.dp else 8.dp), message = msg.text, mediaUrl = msg.mediaUrl, mediaType = msg.type, @@ -229,12 +300,49 @@ fun ChatThreadScreen( senderName = if (!isMine && isGroup && displayName.isNotBlank()) displayName else null, senderActivity = if (!isMine && isGroup) activity else null, type = bubbleType, + timestamp = msg.timestamp, + seenText = seenText, + isLastInSequence = isLastInSequence, + onMediaClick = { imagePreviewUrl = it }, onLocationClick = onLocClick ) } } } + + imagePreviewUrl?.let { previewUrl -> + Dialog( + onDismissRequest = { imagePreviewUrl = null }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.88f)) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = previewUrl, + contentDescription = "Preview de imagen", + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.Fit + ) + } + } + } } } +private fun createCameraImageUri(context: android.content.Context): Uri { + val imageFile = File.createTempFile("chat_camera_", ".jpg", context.filesDir) + return FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + imageFile + ) +} + diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt index f2d264b..bf45e36 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt @@ -1,5 +1,11 @@ package com.appnotresponding.rumbo.ui.screens.friends +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.provider.ContactsContract +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -19,10 +25,17 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import com.appnotresponding.rumbo.R import androidx.navigation.NavHostController import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens +import com.appnotresponding.rumbo.ui.components.atoms.RumboButton +import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonSize +import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle import com.appnotresponding.rumbo.ui.components.atoms.RumboTextField import com.appnotresponding.rumbo.ui.components.molecules.friends.UserSearchResultItem import com.appnotresponding.rumbo.ui.components.molecules.friends.FriendRequestItem @@ -44,8 +57,21 @@ fun FriendsScreen( val currentUser = userState ?: sampleUser.copy(name = "Cargando...") val friendsState by friendsViewModel.uiState.collectAsState() val myUid = FirebaseAuth.getInstance().currentUser?.uid ?: "" + val context = LocalContext.current var searchQuery by remember { mutableStateOf("") } + var contactDiscoveryActive by remember { mutableStateOf(false) } + + val contactsPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + val contacts = readContactKeys(context) + contactDiscoveryActive = true + searchQuery = "" + friendsViewModel.searchUsersByContacts(contacts.emails, contacts.phones) + } + } FriendsTemplate( currentUser = currentUser, @@ -57,6 +83,7 @@ fun FriendsScreen( value = searchQuery, onValueChange = { searchQuery = it + contactDiscoveryActive = false if (it.isBlank()) { friendsViewModel.clearSearch() } else { @@ -69,7 +96,27 @@ fun FriendsScreen( Spacer(modifier = Modifier.height(16.dp)) - if (searchQuery.isNotBlank()) { + RumboButton( + modifier = Modifier.fillMaxWidth(), + text = "Buscar amigos en contactos", + onClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) { + val contacts = readContactKeys(context) + contactDiscoveryActive = true + searchQuery = "" + friendsViewModel.searchUsersByContacts(contacts.emails, contacts.phones) + } else { + contactsPermissionLauncher.launch(Manifest.permission.READ_CONTACTS) + } + }, + style = RumboButtonStyle.Secondary, + size = RumboButtonSize.Medium, + icon = painterResource(R.drawable.ic_user_add) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (searchQuery.isNotBlank() || contactDiscoveryActive) { if (friendsState.isSearching) { Text( text = "Buscando...", @@ -150,3 +197,45 @@ fun FriendsScreen( } } } + +private data class ContactKeys( + val emails: Set, + val phones: Set +) + +private fun readContactKeys(context: Context): ContactKeys { + val emails = mutableSetOf() + val phones = mutableSetOf() + + context.contentResolver.query( + ContactsContract.CommonDataKinds.Email.CONTENT_URI, + arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS), + null, + null, + null + )?.use { cursor -> + val emailIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS) + if (emailIndex >= 0) { + while (cursor.moveToNext()) { + cursor.getString(emailIndex)?.takeIf { it.isNotBlank() }?.let { emails.add(it) } + } + } + } + + context.contentResolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER), + null, + null, + null + )?.use { cursor -> + val phoneIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) + if (phoneIndex >= 0) { + while (cursor.moveToNext()) { + cursor.getString(phoneIndex)?.takeIf { it.isNotBlank() }?.let { phones.add(it) } + } + } + } + + return ContactKeys(emails = emails, phones = phones) +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt index 2331917..0f7c7b7 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt @@ -21,6 +21,7 @@ fun ChatThreadTemplate( chatAvatarUser: User, isGroup: Boolean = false, isMuted: Boolean = false, + isOnline: Boolean = false, onMuteClick: (() -> Unit)? = null, onLeaveClick: (() -> Unit)? = null, onBackClick: (() -> Unit)? = null, @@ -28,8 +29,10 @@ fun ChatThreadTemplate( onMessageInputValueChange: (String) -> Unit = {}, onSendClick: () -> Unit = {}, onImageClick: () -> Unit = {}, + onCameraClick: () -> Unit = {}, onLocationClick: () -> Unit = {}, onMicClick: () -> Unit = {}, + isRecordingAudio: Boolean = false, content: @Composable () -> Unit ) { Scaffold(contentWindowInsets = WindowInsets(0), topBar = { @@ -38,6 +41,7 @@ fun ChatThreadTemplate( activity = chatSubtitle, isGroup = isGroup, isMuted = isMuted, + isOnline = isOnline, onMuteClick = onMuteClick, onLeaveClick = onLeaveClick, onBackClick = onBackClick @@ -53,8 +57,10 @@ fun ChatThreadTemplate( onValueChange = onMessageInputValueChange, onSendClick = onSendClick, onImageClick = onImageClick, + onCameraClick = onCameraClick, onLocationClick = onLocationClick, - onMicClick = onMicClick + onMicClick = onMicClick, + isRecordingAudio = isRecordingAudio ) } }) { paddingValues -> diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt index 920aa19..0231f44 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt @@ -9,6 +9,8 @@ import com.google.firebase.storage.FirebaseStorage import android.net.Uri import com.google.firebase.database.DatabaseError import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.MutableData +import com.google.firebase.database.Transaction import com.google.firebase.database.ValueEventListener import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -18,7 +20,8 @@ import kotlinx.coroutines.flow.update data class ChatThreadState( val messages: List = emptyList(), val isSending: Boolean = false, - val messageAuthors: Map = emptyMap() + val messageAuthors: Map = emptyMap(), + val lastReadTimestamp: Long = 0 ) class ChatThreadViewModel : ViewModel() { @@ -32,6 +35,8 @@ class ChatThreadViewModel : ViewModel() { private var currentListener: ValueEventListener? = null private var currentRef: com.google.firebase.database.DatabaseReference? = null + private var currentMetaListener: ValueEventListener? = null + private var currentMetaRef: com.google.firebase.database.DatabaseReference? = null private val dbUsers = db.getReference("users") private val userCache = mutableMapOf() @@ -81,6 +86,22 @@ class ChatThreadViewModel : ViewModel() { currentRef?.let { ref -> currentListener?.let { ref.removeEventListener(it) } } + currentMetaRef?.let { ref -> + currentMetaListener?.let { ref.removeEventListener(it) } + } + + val myUid = auth.currentUser?.uid ?: "" + val metaRef = db.getReference("chats").child(chatId) + currentMetaRef = metaRef + currentMetaListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val lastRead = snapshot.child("lastReadBy").child(myUid).getValue(Long::class.java) ?: 0L + _uiState.update { it.copy(lastReadTimestamp = lastRead) } + } + + override fun onCancelled(error: DatabaseError) {} + } + metaRef.addValueEventListener(currentMetaListener!!) val ref = db.getReference("messages").child(chatId) currentRef = ref @@ -93,7 +114,6 @@ class ChatThreadViewModel : ViewModel() { messages.add(msg) } val parts = chatId.split("_") - val myUid = auth.currentUser?.uid ?: "" val otherUid = parts.firstOrNull { it != myUid } resolveUsersAndEmit(messages.sortedBy { m -> m.timestamp }, otherUid) } @@ -109,6 +129,22 @@ class ChatThreadViewModel : ViewModel() { currentRef?.let { ref -> currentListener?.let { ref.removeEventListener(it) } } + currentMetaRef?.let { ref -> + currentMetaListener?.let { ref.removeEventListener(it) } + } + + val myUid = auth.currentUser?.uid ?: "" + val metaRef = db.getReference("groupChats").child(placeId) + currentMetaRef = metaRef + currentMetaListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val lastRead = snapshot.child("lastReadBy").child(myUid).getValue(Long::class.java) ?: 0L + _uiState.update { it.copy(lastReadTimestamp = lastRead) } + } + + override fun onCancelled(error: DatabaseError) {} + } + metaRef.addValueEventListener(currentMetaListener!!) val ref = db.getReference("groupMessages").child(placeId) currentRef = ref @@ -139,6 +175,7 @@ class ChatThreadViewModel : ViewModel() { if (participants.size == 2) { db.getReference("chats").child(chatId).child("participants").setValue(participants) } + val recipientUid = participants.firstOrNull { it != myUid } val ref = db.getReference("messages").child(chatId) val msgId = ref.push().key ?: return @@ -151,6 +188,8 @@ class ChatThreadViewModel : ViewModel() { ref.child(msgId).setValue(msg).addOnSuccessListener { db.getReference("chats").child(chatId).child("lastMessage").setValue(text) db.getReference("chats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + db.getReference("chats").child(chatId).child("lastSenderId").setValue(myUid) + recipientUid?.let { incrementUnreadCount("chats", chatId, it) } _uiState.update { it.copy(isSending = false) } }.addOnFailureListener { _uiState.update { it.copy(isSending = false) } @@ -174,6 +213,8 @@ class ChatThreadViewModel : ViewModel() { ref.child(msgId).setValue(msg).addOnSuccessListener { db.getReference("groupChats").child(placeId).child("lastMessage").setValue("${senderName}: $text") db.getReference("groupChats").child(placeId).child("lastMessageTimestamp").setValue(msg.timestamp) + db.getReference("groupChats").child(placeId).child("lastSenderId").setValue(myUid) + incrementGroupUnreadCounts(placeId, myUid) _uiState.update { it.copy(isSending = false) } }.addOnFailureListener { _uiState.update { it.copy(isSending = false) } @@ -205,6 +246,8 @@ class ChatThreadViewModel : ViewModel() { if (isGroup) { db.getReference("groupChats").child(chatId).child("lastMessage").setValue("${senderName ?: ""}: 📍 Ubicación") db.getReference("groupChats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + db.getReference("groupChats").child(chatId).child("lastSenderId").setValue(myUid) + incrementGroupUnreadCounts(chatId, myUid) } else { val parts = chatId.split("_") if (parts.size == 2) { @@ -213,6 +256,8 @@ class ChatThreadViewModel : ViewModel() { } db.getReference("chats").child(chatId).child("lastMessage").setValue("📍 Ubicación") db.getReference("chats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + db.getReference("chats").child(chatId).child("lastSenderId").setValue(myUid) + chatId.split("_").firstOrNull { it != myUid }?.let { incrementUnreadCount("chats", chatId, it) } } _uiState.update { it.copy(isSending = false) } }.addOnFailureListener { @@ -243,6 +288,8 @@ class ChatThreadViewModel : ViewModel() { if (isGroup) { db.getReference("groupChats").child(chatId).child("lastMessage").setValue("${senderName ?: ""}: ${msg.text}") db.getReference("groupChats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + db.getReference("groupChats").child(chatId).child("lastSenderId").setValue(myUid) + incrementGroupUnreadCounts(chatId, myUid) } else { val parts = chatId.split("_") if (parts.size == 2) { @@ -251,6 +298,8 @@ class ChatThreadViewModel : ViewModel() { } db.getReference("chats").child(chatId).child("lastMessage").setValue(msg.text) db.getReference("chats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + db.getReference("chats").child(chatId).child("lastSenderId").setValue(myUid) + chatId.split("_").firstOrNull { it != myUid }?.let { incrementUnreadCount("chats", chatId, it) } } _uiState.update { state -> state.copy(isSending = false) } }.addOnFailureListener { @@ -262,11 +311,57 @@ class ChatThreadViewModel : ViewModel() { } } + fun markChatAsRead(chatId: String, isGroup: Boolean) { + val myUid = auth.currentUser?.uid ?: return + val now = System.currentTimeMillis() + val metaRef = db.getReference(if (isGroup) "groupChats" else "chats").child(chatId) + metaRef.child("lastReadBy").child(myUid).setValue(now) + metaRef.child("unreadCounts").child(myUid).setValue(0) + + val messagesRef = db.getReference(if (isGroup) "groupMessages" else "messages").child(chatId) + messagesRef.get().addOnSuccessListener { snapshot -> + snapshot.children.forEach { child -> + val senderId = child.child("senderId").value as? String ?: return@forEach + if (senderId != myUid) { + child.ref.child("seenBy").child(myUid).setValue(true) + } + } + } + } + + private fun incrementGroupUnreadCounts(placeId: String, myUid: String) { + db.getReference("groupChats").child(placeId).child("participants").get().addOnSuccessListener { snapshot -> + snapshot.children.mapNotNull { it.key }.filter { it != myUid }.forEach { participantUid -> + incrementUnreadCount("groupChats", placeId, participantUid) + } + } + } + + private fun incrementUnreadCount(root: String, chatId: String, recipientUid: String) { + db.getReference(root).child(chatId).child("unreadCounts").child(recipientUid) + .runTransaction(object : Transaction.Handler { + override fun doTransaction(currentData: MutableData): Transaction.Result { + val current = when (val value = currentData.value) { + is Long -> value.toInt() + is Int -> value + else -> 0 + } + currentData.value = current + 1 + return Transaction.success(currentData) + } + + override fun onComplete(error: DatabaseError?, committed: Boolean, currentData: DataSnapshot?) {} + }) + } + override fun onCleared() { super.onCleared() clearUserListeners() currentRef?.let { ref -> currentListener?.let { ref.removeEventListener(it) } } + currentMetaRef?.let { ref -> + currentMetaListener?.let { ref.removeEventListener(it) } + } } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt index 7252b89..0be80d6 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt @@ -21,6 +21,7 @@ data class ChatListState( val selectedChatId: String = "", val selectedChatTitle: String = "", val selectedChatPhoto: String? = null, + val selectedChatIsOnline: Boolean = false, val isGroupChat: Boolean = false ) @@ -115,6 +116,9 @@ class ChatViewModel : ViewModel() { } val lastMessage = child.child("lastMessage").value as? String ?: "" val lastTimestamp = child.child("lastMessageTimestamp").value as? Long ?: 0L + val unreadCount = (child.child("unreadCounts").child(myUid).getValue(Int::class.java) + ?: child.child("unreadCounts").child(myUid).getValue(Long::class.java)?.toInt() + ?: 0).coerceAtLeast(0) db.getReference("friendships").child(myUid).child(otherUid).get().addOnSuccessListener { friendshipSnap -> val areFriends = friendshipSnap.exists() && friendshipSnap.value == true @@ -135,8 +139,10 @@ class ChatViewModel : ViewModel() { otherUserName = user.name, otherUserPhotoUrl = user.profilePictureUrl, otherUserActivity = user.activity, + isOtherUserOnline = user.isOnline, lastMessage = lastMessage, - lastMessageTimestamp = lastTimestamp + lastMessageTimestamp = lastTimestamp, + unreadCount = unreadCount ) ) } else { @@ -199,6 +205,9 @@ class ChatViewModel : ViewModel() { val placeName = snapshot.child("placeName").value as? String ?: "" val lastMessage = snapshot.child("lastMessage").value as? String ?: "" val lastTimestamp = snapshot.child("lastMessageTimestamp").value as? Long ?: 0L + val unreadCount = (snapshot.child("unreadCounts").child(myUid).getValue(Int::class.java) + ?: snapshot.child("unreadCounts").child(myUid).getValue(Long::class.java)?.toInt() + ?: 0).coerceAtLeast(0) val mutedByMap = mutableMapOf() for (muteChild in snapshot.child("mutedBy").children) { val muteKey = muteChild.key ?: continue @@ -210,6 +219,7 @@ class ChatViewModel : ViewModel() { placeName = placeName, lastMessage = lastMessage, lastMessageTimestamp = lastTimestamp, + unreadCount = unreadCount, mutedBy = mutedByMap ) @@ -226,12 +236,13 @@ class ChatViewModel : ViewModel() { } } - fun selectDirectChat(chatId: String, chatTitle: String, photoUrl: String?) { + fun selectDirectChat(chatId: String, chatTitle: String, photoUrl: String?, isOnline: Boolean = false) { _uiState.update { it.copy( selectedChatId = chatId, selectedChatTitle = chatTitle, selectedChatPhoto = photoUrl, + selectedChatIsOnline = isOnline, isGroupChat = false ) } @@ -243,6 +254,7 @@ class ChatViewModel : ViewModel() { selectedChatId = placeId, selectedChatTitle = placeName, selectedChatPhoto = null, + selectedChatIsOnline = false, isGroupChat = true ) } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt index bb688f2..f1fbdca 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt @@ -181,6 +181,46 @@ class FriendsViewModel : ViewModel() { } } + fun searchUsersByContacts(emails: Set, phones: Set) { + val myUid = auth.currentUser?.uid ?: return + if (emails.isEmpty() && phones.isEmpty()) { + _uiState.update { + it.copy( + searchResults = emptyList(), + searchError = "No encontramos emails o teléfonos en tus contactos" + ) + } + return + } + + _uiState.update { it.copy(isSearching = true, searchError = null) } + val normalizedEmails = emails.map { it.lowercase().trim() }.toSet() + val normalizedPhones = phones.map { normalizePhone(it) }.filter { it.isNotBlank() }.toSet() + + dbUsers.get().addOnSuccessListener { snapshot -> + val results = mutableListOf() + for (child in snapshot.children) { + val user = child.getValue(User::class.java) ?: continue + val userEmail = user.email.lowercase().trim() + val userPhone = normalizePhone(user.phone) + val matchesEmail = userEmail.isNotBlank() && normalizedEmails.contains(userEmail) + val matchesPhone = userPhone.isNotBlank() && normalizedPhones.contains(userPhone) + if (user.id != myUid && (matchesEmail || matchesPhone)) { + results.add(user) + } + } + _uiState.update { + it.copy( + searchResults = results.distinctBy { user -> user.id }, + isSearching = false, + searchError = if (results.isEmpty()) "No encontramos amigos de Rumbo en tus contactos" else null + ) + } + }.addOnFailureListener { + _uiState.update { state -> state.copy(isSearching = false, searchError = "Error al revisar contactos") } + } + } + fun addFriend(targetUid: String) { val myUid = auth.currentUser?.uid ?: return if (targetUid == myUid) return @@ -219,6 +259,10 @@ class FriendsViewModel : ViewModel() { _uiState.update { it.copy(searchResults = emptyList(), searchError = null) } } + private fun normalizePhone(phone: String): String { + return phone.filter { it.isDigit() }.takeLast(10) + } + private fun clearAllListeners() { val uid = auth.currentUser?.uid if (uid != null) { diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt index b89db5c..6e164a9 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt @@ -6,6 +6,7 @@ import com.google.firebase.auth.FirebaseAuth import com.google.firebase.database.DataSnapshot import com.google.firebase.database.DatabaseError import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.ServerValue import com.google.firebase.database.ValueEventListener import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,7 +14,10 @@ import kotlinx.coroutines.flow.asStateFlow class UserViewModel : ViewModel() { private val auth = FirebaseAuth.getInstance() - private val dbRef = FirebaseDatabase.getInstance().getReference("users") + private val database = FirebaseDatabase.getInstance() + private val dbRef = database.getReference("users") + private val connectedRef = database.getReference(".info/connected") + private var presenceListener: ValueEventListener? = null private val _currentUserState = MutableStateFlow(null) val currentUserState: StateFlow = _currentUserState.asStateFlow() @@ -24,12 +28,30 @@ class UserViewModel : ViewModel() { val uid = firebaseAuth.currentUser?.uid if (uid != null) { fetchUserData(uid) + setupPresence(uid) } else { _currentUserState.value = null } } } + private fun setupPresence(uid: String) { + presenceListener?.let { connectedRef.removeEventListener(it) } + val userStatusRef = dbRef.child(uid) + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + if (snapshot.getValue(Boolean::class.java) != true) return + userStatusRef.child("isOnline").onDisconnect().setValue(false) + userStatusRef.child("lastSeenAt").onDisconnect().setValue(ServerValue.TIMESTAMP) + userStatusRef.child("isOnline").setValue(true) + } + + override fun onCancelled(error: DatabaseError) {} + } + presenceListener = listener + connectedRef.addValueEventListener(listener) + } + private fun fetchUserData(uid: String) { android.util.Log.d("UserViewModel", "Starting ValueEventListener for uid: $uid") dbRef.child(uid).addValueEventListener(object : ValueEventListener { @@ -80,5 +102,14 @@ class UserViewModel : ViewModel() { android.util.Log.e("UserViewModel", "Failed to set activity to $activity: ${e.message}", e) } } + + override fun onCleared() { + super.onCleared() + presenceListener?.let { connectedRef.removeEventListener(it) } + auth.currentUser?.uid?.let { uid -> + dbRef.child(uid).child("isOnline").setValue(false) + dbRef.child(uid).child("lastSeenAt").setValue(ServerValue.TIMESTAMP) + } + } } diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000..c746152 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_cancel.xml b/app/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 0000000..2539a00 --- /dev/null +++ b/app/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_minus.xml b/app/src/main/res/drawable/ic_minus.xml index 88184ed..41a21cf 100644 --- a/app/src/main/res/drawable/ic_minus.xml +++ b/app/src/main/res/drawable/ic_minus.xml @@ -1,10 +1,9 @@ - - \ No newline at end of file + + diff --git a/app/src/main/res/drawable/ic_recording.xml b/app/src/main/res/drawable/ic_recording.xml new file mode 100644 index 0000000..5633b88 --- /dev/null +++ b/app/src/main/res/drawable/ic_recording.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/outline_cancel_24.xml b/app/src/main/res/drawable/outline_cancel_24.xml deleted file mode 100644 index 0c7e845..0000000 --- a/app/src/main/res/drawable/outline_cancel_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - -