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