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 a02f7961c94c4d28e343c24b717169749c8b71d8 Mon Sep 17 00:00:00 2001 From: Miguel4950 Date: Mon, 1 Jun 2026 19:18:45 -0500 Subject: [PATCH 2/2] Revert "Feature/amigos y chat mejorado" --- 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, 230 insertions(+), 2113 deletions(-) delete mode 100644 app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt delete mode 100644 app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt delete mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt delete mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt delete mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/friends/FriendsList.kt delete mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt delete mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt delete mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt delete mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt delete 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 593be21..1902b82 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,7 +5,6 @@ - = 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 deleted file mode 100644 index fdf4914..0000000 --- a/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt +++ /dev/null @@ -1,12 +0,0 @@ -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 8bc544e..d94f998 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt @@ -1,10 +1,7 @@ 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 focusLocation: LatLng? = null + val selectedPlace: Place? = 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 5b5c671..419bab2 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/user.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/user.kt @@ -10,9 +10,7 @@ data class User( val latitude: Double = 0.0, val longitude: Double = 0.0, val altitude: Double = 0.0, - val profilePictureUrl: String? = null, - val sharingLocation: Boolean = false, - val activity: String? = null + val profilePictureUrl: String? = null ) val sampleUser = User( @@ -24,6 +22,5 @@ val sampleUser = User( latitude = 0.0, longitude = 0.0, altitude = 0.0, - profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg", - sharingLocation = false + profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg" ) \ 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 941118b..526fd99 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt @@ -1,6 +1,7 @@ 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 @@ -9,25 +10,20 @@ 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, @@ -36,46 +32,43 @@ enum class AppScreens { ChatThread, Plan, Itinerary, - OnBoarding, - Friends + OnBoarding } @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, friendsViewModel) + composable (route = AppScreens.Map.name) { + MapScreen(navController, placesViewModel, locationViewModel, userViewModel) } - composable(route = AppScreens.Chat.name) { - ChatListScreen(navController, userViewModel, chatViewModel, placesViewModel) + composable (route = AppScreens.Chat.name) { + ChatListScreen(navController, userViewModel) } - composable(route = AppScreens.ChatThread.name) { - ChatThreadScreen(navController, chatViewModel, chatThreadViewModel, userViewModel, locationViewModel, placesViewModel) + composable(route = AppScreens.ChatThread.name){ + ChatThreadScreen(navController) } - 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 ad69ace..d2f49ab 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,7 +2,6 @@ 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 @@ -11,17 +10,11 @@ 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 @@ -30,8 +23,6 @@ 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 @@ -44,8 +35,6 @@ 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) { @@ -93,14 +82,11 @@ enum class ChatBubbleType { @Composable fun ChatBubble( message: String, - mediaUrl: String? = null, - mediaType: String? = null, + messageImage: ImageRequest? = null, isUserMessage: Boolean, senderName: String? = null, - senderActivity: String? = null, type: ChatBubbleType = ChatBubbleType.Regular, - place: Place? = null, - onLocationClick: (() -> Unit)? = null + place: Place? = null ) { val horizontalAlignment = if (isUserMessage) { Alignment.End @@ -126,22 +112,6 @@ 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( @@ -150,125 +120,36 @@ fun ChatBubble( Column( modifier = Modifier .widthIn(max = 280.dp) - .then( - if (mediaUrl != null) Modifier.width(240.dp) else Modifier - ) - .background(backgroundColor, bubbleShape), + .background(backgroundColor, MaterialTheme.shapes.large), horizontalAlignment = horizontalAlignment, verticalArrangement = Arrangement.spacedBy(4.dp), ) { Column( - modifier = Modifier - .then(if (mediaUrl != null) Modifier.fillMaxWidth() else Modifier) - .padding(horizontal = 16.dp, vertical = 10.dp), - horizontalAlignment = Alignment.Start, + modifier = Modifier.padding(12.dp), + horizontalAlignment = horizontalAlignment, verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (senderName != null) { - 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 - ) - } - } + Text( + text = senderName, + style = MaterialTheme.typography.labelLarge, + color = contentColor + ) } - if (mediaUrl != null && mediaType == "image") { + if (messageImage != null) { AsyncImage( - modifier = Modifier.clip(MaterialTheme.shapes.medium).fillMaxWidth(), - model = mediaUrl, - contentScale = ContentScale.FillWidth, + modifier = Modifier.clip(MaterialTheme.shapes.medium), + model = messageImage, 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 + ) } } } @@ -284,10 +165,9 @@ fun ChatBubble( .widthIn(max = 280.dp) .background( backgroundColor, - bubbleShape + MaterialTheme.shapes.large ) - .clip(bubbleShape) - .clickable(enabled = onLocationClick != null) { onLocationClick?.invoke() }, + .clip(MaterialTheme.shapes.large), ) { Row( modifier = Modifier @@ -331,7 +211,7 @@ fun ChatBubble( Column( modifier = Modifier .widthIn(max = 280.dp) - .background(backgroundColor, bubbleShape), + .background(backgroundColor, MaterialTheme.shapes.large), horizontalAlignment = horizontalAlignment, verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -342,28 +222,11 @@ fun ChatBubble( verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (senderName != null) { - 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( + text = senderName, + style = MaterialTheme.typography.labelLarge, + color = contentColor + ) } Text( @@ -444,8 +307,7 @@ private fun ChatBubblePreviewContent() { // Regular - received with image ChatBubble( message = "¡Hola! ¿Cómo estás?", - mediaUrl = null, - mediaType = null, + messageImage = placeholderImage, isUserMessage = false, senderName = "Carlos", type = ChatBubbleType.Regular @@ -453,8 +315,7 @@ private fun ChatBubblePreviewContent() { // Regular - sent with image ChatBubble( message = "¡Todo bien! ¿Y tú?", - mediaUrl = null, - mediaType = null, + messageImage = placeholderImage, 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 004fca5..7c203e5 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 = "Compartir ubicación", + contentDescription = "Enviar 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 deleted file mode 100644 index 0e5b70c..0000000 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index 0c137a7..0000000 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt +++ /dev/null @@ -1,94 +0,0 @@ -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 1fed3a5..61a5434 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,7 +18,6 @@ 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 @@ -45,7 +44,6 @@ 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 cf38d8d..a9681b6 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, AppScreens.Friends.name -> NavItem.Chat + AppScreens.Chat.name, AppScreens.ChatThread.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 2ac68f4..35112ac 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,13 +9,6 @@ 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 @@ -85,15 +78,7 @@ fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) { * (por ejemplo, "Rumbo al Museo Nacional"). */ @Composable -fun ChatTopBar( - u: User, - activity: String? = null, - isGroup: Boolean = false, - isMuted: Boolean = false, - onMuteClick: (() -> Unit)? = null, - onLeaveClick: (() -> Unit)? = null, - onBackClick: (() -> Unit)? = null -) { +fun ChatTopBar(u: User, activity: String? = null) { val bottomRoundedShape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) val displayName = u.name.replace(Regex(" +$"), "") Surface( @@ -104,57 +89,24 @@ fun ChatTopBar( .fillMaxWidth() .padding(16.dp) .padding(top = 32.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.Start ) { - 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 { + Avatar(user = u) + Column { + + Text( + text = displayName, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 8.dp), + color = MaterialTheme.colorScheme.onSurface + ) + if (activity != null) { Text( - text = displayName, - style = MaterialTheme.typography.labelLarge, + text = activity, + style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 8.dp), - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.primary ) - 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 deleted file mode 100644 index a7f14ac..0000000 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/friends/FriendsList.kt +++ /dev/null @@ -1,53 +0,0 @@ -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 1ca3ba2..a9f69d5 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,204 +1,85 @@ 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.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.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.viewModel.UserViewModel -import com.google.firebase.auth.FirebaseAuth + @Composable -fun ChatListScreen( - controller: NavHostController, - userViewModel: UserViewModel, - chatViewModel: ChatViewModel, - placesViewModel: PlacesViewModel -) { +fun ChatListScreen(controller: NavHostController, userViewModel: UserViewModel) { 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 ?: "" - 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 - ) - } - } + 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) + ) - 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 - ) - } - } - } + 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 } - } + }) { + ChatList( + chatItems = mockChats, + onChatClick = { controller.navigate(AppScreens.ChatThread.name) }) } } -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 +// +//@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 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..945c4d0 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,240 +1,112 @@ 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.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavController 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.ui.components.molecules.chat.ChatBubble +import com.appnotresponding.rumbo.navigation.AppScreens 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.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 +import com.appnotresponding.rumbo.ui.theme.RumboTheme @Composable fun ChatThreadScreen( - controller: NavHostController, - chatViewModel: ChatViewModel, - chatThreadViewModel: ChatThreadViewModel, - userViewModel: UserViewModel, - locationViewModel: UserLocationViewModel, - placesViewModel: PlacesViewModel + controller: NavHostController ) { - 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 listState = rememberLazyListState() - - val chatId = chatState.selectedChatId - val isGroup = chatState.isGroupChat - - var mediaRecorder by remember { mutableStateOf(null) } - var audioFile by remember { mutableStateOf(null) } - var isRecording by remember { mutableStateOf(false) } - - val context = LocalContext.current - - val imagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri: Uri? -> - if (uri != null) { - chatThreadViewModel.sendMediaMessage(chatId, currentUser.name, uri, isGroup, "image") - } - } + val brandonUser = sampleUser.copy(name = "Brandon") - val permissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission() - ) { isGranted -> - // Handle permission result if needed - } - - LaunchedEffect(chatId) { - if (chatId.isNotBlank()) { - if (isGroup) { - chatThreadViewModel.listenToGroupMessages(chatId) - } else { - chatThreadViewModel.listenToMessages(chatId) - } - } - } - - LaunchedEffect(threadState.messages.size) { - if (threadState.messages.isNotEmpty()) { - listState.animateScrollToItem(threadState.messages.size - 1) - } - } - - val avatarUser = sampleUser.copy( - name = chatState.selectedChatTitle, - profilePictureUrl = chatState.selectedChatPhoto + val museoNacional = samplePlace.copy( + name = "Museo Nacional", + openHours = emptyList(), + price = "$ 40.000 COP" ) - 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] - + 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 = 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() - }, + chatTitle = brandonUser.name, + chatSubtitle = "", + chatAvatarUser = brandonUser, 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 + messageInput = "" + }) { + ChatThread(messages = messages) + } +} + +@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 museoNacional = samplePlace.copy( + name = "Museo Nacional", openHours = emptyList(), price = "$ 40.000 COP" + ) + + 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 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 - ) - } - } - } + RumboTheme(darkTheme = true) { + ChatThreadScreen(controller = rememberNavController()) } } +@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) + ) + RumboTheme(darkTheme = true) { + ChatThreadScreen( + controller = rememberNavController() + ) + } +} 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 deleted file mode 100644 index f2d264b..0000000 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt +++ /dev/null @@ -1,152 +0,0 @@ -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 f6ad0c3..fc5f2cb 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,15 +12,12 @@ 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, - friendsViewModel: FriendsViewModel + userViewModel: UserViewModel ) { val userState by userViewModel.currentUserState.collectAsState() val user = userState ?: sampleUser.copy(name = "Cargando...") @@ -32,7 +29,6 @@ fun MapScreen( popUpTo(controller.graph.startDestinationId) { inclusive = true } launchSingleTop = true } - }, placesViewModel = placesViewModel, locationViewModel = locationViewModel, - userViewModel = userViewModel, friendsViewModel = friendsViewModel + }, placesViewModel = placesViewModel, locationViewModel = locationViewModel ) } \ 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 2331917..ed8d275 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,7 +3,6 @@ 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 @@ -12,57 +11,34 @@ 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, - isGroup = isGroup, - isMuted = isMuted, - onMuteClick = onMuteClick, - onLeaveClick = onLeaveClick, - onBackClick = onBackClick - ) + ChatTopBar(u = chatAvatarUser, activity = chatSubtitle) }, bottomBar = { - Box( - modifier = Modifier - .navigationBarsPadding() - .padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 12.dp) - ) { + Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { MessageComposer( value = messageInputValue, onValueChange = onMessageInputValueChange, - onSendClick = onSendClick, - onImageClick = onImageClick, - onLocationClick = onLocationClick, - onMicClick = onMicClick + onSendClick = onSendClick ) } }) { paddingValues -> Box( modifier = modifier .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 16.dp) + .padding(bottom = paddingValues.calculateBottomPadding()) + .padding(horizontal = 8.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 deleted file mode 100644 index 232d926..0000000 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt +++ /dev/null @@ -1,59 +0,0 @@ -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 501365e..a6d1fa7 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,17 +15,11 @@ 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 @@ -39,7 +33,6 @@ 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 @@ -117,9 +110,7 @@ fun MapTemplate( viewModel: MapViewModel = viewModel(), dropNoteViewModel: DropNoteViewModel = viewModel(), placesViewModel: PlacesViewModel, - locationViewModel: UserLocationViewModel, - userViewModel: UserViewModel, - friendsViewModel: FriendsViewModel + locationViewModel: UserLocationViewModel ) { Log.d("RECOMPOSE", "MapTemplate recomposed") @@ -129,7 +120,6 @@ 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) } @@ -238,22 +228,6 @@ 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), @@ -261,8 +235,7 @@ fun MapTemplate( floatingActionButton = { Column( modifier = Modifier - .width(56.dp) - .padding(bottom = 16.dp), + .width(45.dp), verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom) ) { if (permission.status.isGranted) { @@ -288,31 +261,7 @@ 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) - ) - } - } + } } }, @@ -354,20 +303,6 @@ 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 deleted file mode 100644 index 920aa19..0000000 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt +++ /dev/null @@ -1,272 +0,0 @@ -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 deleted file mode 100644 index 7252b89..0000000 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt +++ /dev/null @@ -1,298 +0,0 @@ -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 deleted file mode 100644 index bb688f2..0000000 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt +++ /dev/null @@ -1,248 +0,0 @@ -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 b49c485..c7742b9 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,12 +37,4 @@ 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 b89db5c..eac0223 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,54 +31,14 @@ 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) { - 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) - } + val user = snapshot.getValue(User::class.java) + _currentUserState.value = user } 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) - } - } } -