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