diff --git a/.gitignore b/.gitignore
index e5cbb64..80e26d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,5 @@ google-services.json
# Android Profiling
*.hprof
+
+.gradle-sandbox/
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1902b82..0ace41c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,9 @@
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt b/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt
new file mode 100644
index 0000000..9b18473
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt
@@ -0,0 +1,22 @@
+package com.appnotresponding.rumbo.models
+
+data class ChatConversation(
+ val chatId: String = "",
+ val otherUserId: String = "",
+ val otherUserName: String = "",
+ val otherUserPhotoUrl: String? = null,
+ val otherUserActivity: String? = null,
+ val isOtherUserOnline: Boolean = false,
+ val lastMessage: String = "",
+ val lastMessageTimestamp: Long = 0,
+ val unreadCount: Int = 0
+)
+
+data class GroupChat(
+ val placeId: String = "",
+ val placeName: String = "",
+ val lastMessage: String = "",
+ val lastMessageTimestamp: Long = 0,
+ val unreadCount: Int = 0,
+ val mutedBy: Map = emptyMap()
+)
diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt b/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt
new file mode 100644
index 0000000..9086cc3
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt
@@ -0,0 +1,13 @@
+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,
+ val seenBy: Map = emptyMap()
+)
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..99c6817 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,11 @@ 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 isOnline: Boolean = false,
+ val lastSeenAt: Long = 0
)
val sampleUser = User(
@@ -22,5 +26,6 @@ val sampleUser = User(
latitude = 0.0,
longitude = 0.0,
altitude = 0.0,
- profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg"
-)
\ No newline at end of file
+ profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg",
+ sharingLocation = false
+)
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/atoms/UserProfileBubble.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt
index ae5b1f5..7f126af 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt
@@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -33,6 +34,7 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.allowHardware
+import com.appnotresponding.rumbo.R
import com.appnotresponding.rumbo.models.User
import com.appnotresponding.rumbo.models.sampleUser
import com.appnotresponding.rumbo.ui.theme.RumboTheme
@@ -95,7 +97,7 @@ fun UserProfileBubble(
if (!user.profilePictureUrl.isNullOrEmpty() && imageLoadFailed) {
Icon(
modifier = Modifier.size(bubbleSize * 0.5f),
- imageVector = Icons.Rounded.Person,
+ painter = painterResource(R.drawable.ic_user),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt
index d2f49ab..7a0b374 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt
@@ -2,32 +2,43 @@ 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
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.SubcomposeAsyncImage
-import coil3.request.ImageRequest
+import com.appnotresponding.rumbo.BuildConfig
import com.appnotresponding.rumbo.R
import com.appnotresponding.rumbo.models.Place
import com.appnotresponding.rumbo.models.samplePlace
@@ -35,6 +46,11 @@ 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
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
@Composable
fun ChatSeparator(text: String) {
@@ -81,12 +97,20 @@ enum class ChatBubbleType {
*/
@Composable
fun ChatBubble(
+ modifier: Modifier = Modifier,
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,
+ timestamp: Long = 0,
+ seenText: String? = null,
+ isLastInSequence: Boolean = true,
+ onMediaClick: ((String) -> Unit)? = null,
+ onLocationClick: (() -> Unit)? = null
) {
val horizontalAlignment = if (isUserMessage) {
Alignment.End
@@ -95,15 +119,15 @@ fun ChatBubble(
}
val backgroundColor = if (isUserMessage) {
- MaterialTheme.colorScheme.secondary
+ MaterialTheme.colorScheme.secondaryContainer
} else {
- MaterialTheme.colorScheme.primary
+ MaterialTheme.colorScheme.surfaceContainerHighest
}
val contentColor = if (isUserMessage) {
- MaterialTheme.colorScheme.onSecondary
+ MaterialTheme.colorScheme.onSecondaryContainer
} else {
- MaterialTheme.colorScheme.onPrimary
+ MaterialTheme.colorScheme.onSurface
}
val bubbleAlignment = if (isUserMessage) {
@@ -112,44 +136,201 @@ fun ChatBubble(
Arrangement.Start
}
+ val bubbleShape = when {
+ isUserMessage && isLastInSequence -> RoundedCornerShape(
+ topStart = 16.dp,
+ topEnd = 16.dp,
+ bottomStart = 16.dp,
+ bottomEnd = 4.dp
+ )
+ !isUserMessage && isLastInSequence -> RoundedCornerShape(
+ topStart = 16.dp,
+ topEnd = 16.dp,
+ bottomStart = 4.dp,
+ bottomEnd = 16.dp
+ )
+ else -> RoundedCornerShape(16.dp)
+ }
+
when (type) {
ChatBubbleType.Regular -> {
Row(
- modifier = Modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment
+ modifier = modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment
) {
Column(
modifier = Modifier
.widthIn(max = 280.dp)
- .background(backgroundColor, MaterialTheme.shapes.large),
+ .then(if (mediaUrl != null) Modifier.widthIn(min = 220.dp, max = 280.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(16.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(RoundedCornerShape(12.dp))
+ .fillMaxWidth()
+ .clickable(enabled = onMediaClick != null) {
+ onMediaClick?.invoke(mediaUrl)
+ },
+ 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
+ .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(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(34.dp)
+ .background(MaterialTheme.colorScheme.primary, CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ val icon = if (isPreparing) "..." else if (isPlaying) "II" else "▶"
+ Text(
+ text = icon,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onPrimary,
+ textAlign = TextAlign.Center
+ )
+ }
+ Row(
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.spacedBy(3.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val bars = if (isPlaying) listOf(10, 18, 13, 22, 15, 24, 12, 19, 14, 20, 10, 18, 13, 22, 15) else listOf(8, 14, 10, 16, 11, 18, 9, 15, 10, 13, 8, 14, 10, 16, 11)
+ bars.forEach { barHeight ->
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(barHeight.dp)
+ .background(contentColor.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
+ )
+ }
+ }
+ Text(
+ text = if (isPreparing) "..." else "0:00",
+ style = MaterialTheme.typography.labelSmall,
+ color = contentColor.copy(alpha = 0.7f)
+ )
+ }
+ }
+
+ 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
+ )
+ }
+
+ if (timestamp > 0 || seenText != null) {
+ Row(
+ modifier = Modifier.align(Alignment.End),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (timestamp > 0) {
+ Text(
+ text = formatMessageTime(timestamp),
+ style = MaterialTheme.typography.labelSmall,
+ color = contentColor.copy(alpha = 0.62f)
+ )
+ }
+ if (seenText != null) {
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = seenText,
+ style = MaterialTheme.typography.labelSmall,
+ color = if (seenText == "Visto") MaterialTheme.colorScheme.primary else contentColor.copy(alpha = 0.62f)
+ )
+ }
+ }
}
- Text(
- text = message,
- style = MaterialTheme.typography.bodyMedium,
- color = contentColor
- )
}
}
}
@@ -158,16 +339,17 @@ fun ChatBubble(
ChatBubbleType.Location -> {
Row(
- modifier = Modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment
+ modifier = modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment
) {
Column(
modifier = Modifier
.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
@@ -183,22 +365,27 @@ fun ChatBubble(
)
}
- Image(
+ AsyncImage(
modifier = Modifier
.fillMaxWidth()
- .aspectRatio(3f / 2f)
- .clip(
- RoundedCornerShape(
- bottomStart = 28.dp,
- bottomEnd = 28.dp,
- topStart = 0.dp,
- topEnd = 0.dp
- )
- ),
- painter = painterResource(R.mipmap.img_map),
+ .aspectRatio(3f / 2f),
+
+ model = staticMapPreviewUrl(message),
+ fallback = painterResource(R.mipmap.img_map),
+ error = painterResource(R.mipmap.img_map),
contentScale = ContentScale.Crop,
contentDescription = "Mapa de ubicación compartida"
)
+ if (timestamp > 0) {
+ Text(
+ text = formatMessageTime(timestamp),
+ modifier = Modifier
+ .align(Alignment.End)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = contentColor.copy(alpha = 0.7f)
+ )
+ }
}
}
}
@@ -206,12 +393,12 @@ fun ChatBubble(
ChatBubbleType.LiveActivity -> {
if (place != null) {
Row(
- modifier = Modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment
+ modifier = modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment
) {
Column(
modifier = Modifier
.widthIn(max = 280.dp)
- .background(backgroundColor, MaterialTheme.shapes.large),
+ .background(backgroundColor, bubbleShape),
horizontalAlignment = horizontalAlignment,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -222,11 +409,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(
@@ -292,12 +496,20 @@ fun ChatBubble(
}
}
+private fun formatMessageTime(timestamp: Long): String {
+ return SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(timestamp))
+}
+
+private fun staticMapPreviewUrl(message: String): String {
+ val parts = message.removePrefix("Ubicación: ").split(",")
+ val lat = parts.getOrNull(0)?.trim()?.toDoubleOrNull() ?: 4.627293
+ val lng = parts.getOrNull(1)?.trim()?.toDoubleOrNull() ?: -74.063228
+ return "https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=17&size=640x360&scale=2&markers=color:red%7C$lat,$lng&key=${BuildConfig.MAPS_API_KEY}"
+}
+
@Composable
private fun ChatBubblePreviewContent() {
- val context = LocalContext.current
- val placeholderImage = ImageRequest.Builder(context).data(R.mipmap.img_mock).build()
-
Column(
modifier = Modifier
.fillMaxWidth()
@@ -307,7 +519,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 +528,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/ChatListItem.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatListItem.kt
index 691c8e1..52393f2 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatListItem.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatListItem.kt
@@ -41,7 +41,9 @@ fun ChatListItem(
lastMessage: String,
status: String? = null,
timestamp: String,
- hasUnread: Boolean = false
+ hasUnread: Boolean = false,
+ unreadCount: Int = 0,
+ isOnline: Boolean = false
) {
Box(
modifier = modifier
@@ -63,7 +65,7 @@ fun ChatListItem(
verticalAlignment = Alignment.CenterVertically
) {
Box {
- Avatar(user = user)
+ Avatar(user = user, isOnline = isOnline)
}
Column(modifier = Modifier.weight(1f)) {
@@ -101,20 +103,40 @@ fun ChatListItem(
modifier = Modifier.weight(1f)
)
- if (timestamp.isNotEmpty()) {
- Text(
- text = timestamp,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- } else if (hasUnread) {
- Box(
- modifier = Modifier
- .size(8.dp)
- .background(
- color = MaterialTheme.colorScheme.onSurface, shape = CircleShape
+ Column(horizontalAlignment = Alignment.End) {
+ if (timestamp.isNotEmpty()) {
+ Text(
+ text = timestamp,
+ style = MaterialTheme.typography.labelSmall,
+ color = if (unreadCount > 0 || hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ if (unreadCount > 0) {
+ Box(
+ modifier = Modifier
+ .padding(top = 4.dp)
+ .size(22.dp)
+ .background(
+ color = MaterialTheme.colorScheme.primary, shape = CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = if (unreadCount > 99) "99+" else unreadCount.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimary
)
- )
+ }
+ } else if (hasUnread) {
+ Box(
+ modifier = Modifier
+ .padding(top = 4.dp)
+ .size(8.dp)
+ .background(
+ color = MaterialTheme.colorScheme.primary, shape = CircleShape
+ )
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt
index 7c203e5..f77dfe8 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt
@@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PhotoCamera
+import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -45,8 +48,10 @@ fun MessageComposer(
onValueChange: (String) -> Unit = {},
onSendClick: () -> Unit = {},
onImageClick: () -> Unit = {},
+ onCameraClick: () -> Unit = {},
onLocationClick: () -> Unit = {},
- onMicClick: () -> Unit = {}
+ onMicClick: () -> Unit = {},
+ isRecordingAudio: Boolean = false
) {
Surface(
modifier = modifier.fillMaxWidth(),
@@ -57,28 +62,54 @@ fun MessageComposer(
Column(
modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- // Text input area
- BasicTextField(
- value = value,
- onValueChange = onValueChange,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 4.dp),
- textStyle = MaterialTheme.typography.bodyLarge.copy(
- color = MaterialTheme.colorScheme.onSurface
- ),
- decorationBox = { innerTextField ->
- Box {
- if (value.isEmpty()) {
- Text(
- text = "Mensaje",
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
- )
+ if (isRecordingAudio) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_recording),
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.error
+ )
+ Text(
+ text = "Grabando audio",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = "Toca el micrófono para enviar",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ } else {
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp),
+ textStyle = MaterialTheme.typography.bodyLarge.copy(
+ color = MaterialTheme.colorScheme.onSurface
+ ),
+ decorationBox = { innerTextField ->
+ Box {
+ if (value.isEmpty()) {
+ Text(
+ text = "Mensaje",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ )
+ }
+ innerTextField()
}
- innerTextField()
- }
- })
+ })
+ }
// Bottom row: action icons on the left, send button on the right
Row(
@@ -99,38 +130,58 @@ fun MessageComposer(
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
- IconButton(onClick = onLocationClick, modifier = Modifier.size(40.dp)) {
+ IconButton(onClick = onCameraClick, modifier = Modifier.size(40.dp)) {
Icon(
- painter = painterResource(id = R.drawable.ic_marker),
- contentDescription = "Enviar ubicación",
+ painter = painterResource(id = R.drawable.ic_camera),
+ contentDescription = "Tomar foto",
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
- IconButton(onClick = onMicClick, modifier = Modifier.size(40.dp)) {
+ IconButton(onClick = onLocationClick, modifier = Modifier.size(40.dp)) {
Icon(
- painter = painterResource(id = R.drawable.ic_microphone),
- contentDescription = "Grabar audio",
+ painter = painterResource(id = R.drawable.ic_marker),
+ contentDescription = "Compartir ubicación",
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
+ IconButton(onClick = onMicClick, modifier = Modifier.size(40.dp)) {
+ Box(contentAlignment = Alignment.Center) {
+ if (isRecordingAudio) {
+ Box(
+ modifier = Modifier
+ .size(34.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.error.copy(alpha = 0.16f))
+ )
+ }
+ Icon(
+ painter = painterResource(id = R.drawable.ic_microphone),
+ contentDescription = if (isRecordingAudio) "Detener grabación" else "Grabar audio",
+ modifier = Modifier.size(22.dp),
+ tint = if (isRecordingAudio) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
}
// Send button
- IconButton(
- onClick = onSendClick,
- modifier = Modifier
- .size(40.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.secondary)
- ) {
- Icon(
- painter = painterResource(id = R.drawable.ic_send),
- contentDescription = "Enviar mensaje",
- modifier = Modifier.size(20.dp),
- tint = MaterialTheme.colorScheme.onSecondary
- )
+ if (!isRecordingAudio) {
+ IconButton(
+ onClick = onSendClick,
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.secondary)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_send),
+ contentDescription = "Enviar mensaje",
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSecondary
+ )
+ }
}
}
}
@@ -155,4 +206,4 @@ private fun MessageComposerDarkPreview() {
MessageComposer()
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt
new file mode 100644
index 0000000..2e2c295
--- /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.ic_cancel)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt
new file mode 100644
index 0000000..a54aeab
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt
@@ -0,0 +1,104 @@
+package com.appnotresponding.rumbo.ui.components.molecules.friends
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+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 {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .background(MaterialTheme.colorScheme.primary, CircleShape)
+ .clickable(onClick = onAddClick),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_user_add),
+ contentDescription = "Agregar amigo",
+ tint = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt
index ed0c9cb..fb17da8 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt
@@ -79,7 +79,7 @@ fun CancelRoute(onClick: () -> Unit = {}) {
) {
IconButton(onClick = onClick) {
Icon(
- painter = painterResource(R.drawable.outline_cancel_24),
+ painter = painterResource(R.drawable.ic_cancel),
contentDescription = "Cancel Route",
tint = MaterialTheme.colorScheme.onPrimary,
)
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatList.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatList.kt
index 7ff2493..92bde82 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatList.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatList.kt
@@ -32,6 +32,8 @@ fun ChatList(
lastMessage = chat.lastMessage,
status = chat.status,
timestamp = chat.timestamp,
+ unreadCount = chat.unreadCount,
+ hasUnread = chat.hasUnread,
modifier = Modifier.clickable { onChatClick(chat) })
}
}
@@ -42,7 +44,8 @@ data class ChatPreviewData(
val lastMessage: String,
val status: String? = null,
val timestamp: String,
- val hasUnread: Boolean = false
+ val hasUnread: Boolean = false,
+ val unreadCount: Int = 0
)
private val mockChats = listOf(
@@ -89,4 +92,4 @@ private fun AuthPrimaryCTALightPreview() {
RumboTheme(darkTheme = false) {
ChatList(chatItems = mockChats)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/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..4b027fb 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt
@@ -7,14 +7,20 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@@ -28,6 +34,11 @@ import androidx.navigation.compose.rememberNavController
import com.appnotresponding.rumbo.R
import com.appnotresponding.rumbo.navigation.AppScreens
import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.database.DataSnapshot
+import com.google.firebase.database.DatabaseError
+import com.google.firebase.database.FirebaseDatabase
+import com.google.firebase.database.ValueEventListener
enum class NavItem {
Map, Chat, Plan, Itinerary
@@ -43,15 +54,60 @@ enum class NavItem {
fun Nav(
controller: NavController
) {
+ var unreadCount by remember { mutableIntStateOf(0) }
val navBackStackEntry by controller.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val activeItem = when (currentRoute) {
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
}
+
+ DisposableEffect(Unit) {
+ val uid = FirebaseAuth.getInstance().currentUser?.uid
+ if (uid == null) {
+ onDispose {}
+ } else {
+ val db = FirebaseDatabase.getInstance()
+ val directRef = db.getReference("chats")
+ val groupRef = db.getReference("groupChats")
+ var directUnread = 0
+ var groupUnread = 0
+
+ fun readUnread(snapshot: DataSnapshot): Int {
+ return snapshot.children.sumOf { child ->
+ child.child("unreadCounts").child(uid).getValue(Int::class.java)
+ ?: child.child("unreadCounts").child(uid).getValue(Long::class.java)?.toInt()
+ ?: 0
+ }
+ }
+
+ val directListener = object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ directUnread = readUnread(snapshot)
+ unreadCount = directUnread + groupUnread
+ }
+
+ override fun onCancelled(error: DatabaseError) {}
+ }
+ val groupListener = object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ groupUnread = readUnread(snapshot)
+ unreadCount = directUnread + groupUnread
+ }
+
+ override fun onCancelled(error: DatabaseError) {}
+ }
+ directRef.addValueEventListener(directListener)
+ groupRef.addValueEventListener(groupListener)
+ onDispose {
+ directRef.removeEventListener(directListener)
+ groupRef.removeEventListener(groupListener)
+ }
+ }
+ }
Box {
Box(
modifier = Modifier
@@ -118,11 +174,28 @@ fun Nav(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
- Icon(
- painter = painterResource(R.drawable.ic_messages),
- contentDescription = "Chat",
- tint = if (activeItem == NavItem.Chat) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
- )
+ Box {
+ Icon(
+ painter = painterResource(R.drawable.ic_messages),
+ contentDescription = "Chat",
+ tint = if (activeItem == NavItem.Chat) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+ )
+ if (unreadCount > 0) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .size(18.dp)
+ .background(MaterialTheme.colorScheme.primary, CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = if (unreadCount > 9) "9+" else unreadCount.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ }
Text(
text = "Chat",
color = if (activeItem == NavItem.Chat) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt
index 35112ac..f72e9f9 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt
@@ -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
@@ -41,8 +48,7 @@ fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) {
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(16.dp)
- .padding(top = 32.dp),
+ .padding(start = 16.dp, top = 32.dp, end = 16.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -78,7 +84,16 @@ 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,
+ isOnline: 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(
@@ -87,26 +102,58 @@ fun ChatTopBar(u: User, activity: String? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(16.dp)
- .padding(top = 32.dp),
- horizontalArrangement = Arrangement.Start
+ .padding(start = 16.dp, top = 32.dp, end = 16.dp, bottom = 16.dp),
+ 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(
+ painter = painterResource(R.drawable.ic_arrow_left),
+ contentDescription = "Atrás",
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ Avatar(user = u, isOnline = isOnline)
+ 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/components/organisms/map/DropNoteComposer.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/DropNoteComposer.kt
index f5d009b..017ed1f 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/DropNoteComposer.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/DropNoteComposer.kt
@@ -99,7 +99,7 @@ fun DropNoteComposer(
// Botón cámara
IconButton(onClick = onImageClick, modifier = Modifier.size(40.dp)) {
Icon(
- painter = painterResource(id = R.drawable.ic_picture),
+ painter = painterResource(id = R.drawable.ic_camera),
contentDescription = "Cámara",
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt
index b2eb611..2dcc08a 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt
@@ -35,12 +35,14 @@ import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import coil3.compose.AsyncImage
+import com.appnotresponding.rumbo.R
import com.appnotresponding.rumbo.models.RegisterState
import com.appnotresponding.rumbo.models.RegisterViewModel
import com.appnotresponding.rumbo.navigation.AppScreens
@@ -150,7 +152,7 @@ fun SignUpForm(
)
} else {
Icon(
- imageVector = Icons.Rounded.AddAPhoto,
+ painter = painterResource(R.drawable.ic_add_image),
contentDescription = "Seleccionar foto",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(32.dp)
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt
index a9f69d5..602d809 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt
@@ -1,85 +1,210 @@
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.foundation.shape.CircleShape
+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 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)
- )
+ val myUid = FirebaseAuth.getInstance().currentUser?.uid ?: ""
- 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
+ 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,
+ shape = CircleShape
+ ) {
+ 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
+ )
}
- }) {
- ChatList(
- chatItems = mockChats,
- onChatClick = { controller.navigate(AppScreens.ChatThread.name) })
+
+ 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,
+ isOnline = convo.isOtherUserOnline
+ )
+ controller.navigate(AppScreens.ChatThread.name)
+ },
+ user = friendUser,
+ lastMessage = convo.lastMessage,
+ status = convo.otherUserActivity,
+ timestamp = formatTimestamp(convo.lastMessageTimestamp),
+ hasUnread = convo.unreadCount > 0,
+ unreadCount = convo.unreadCount,
+ isOnline = convo.isOtherUserOnline
+ )
+ }
+ }
+
+ 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 = group.unreadCount > 0,
+ unreadCount = group.unreadCount
+ )
+ }
+ }
+
+ 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
+ )
+ }
+ }
+ }
+ }
+ }
}
}
-//
-//@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"
+ }
+}
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..076b781 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt
@@ -1,112 +1,348 @@
package com.appnotresponding.rumbo.ui.screens.chat
+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.itemsIndexed
+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.foundation.background
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import coil3.compose.AsyncImage
+import java.io.File
import androidx.compose.runtime.setValue
-import androidx.compose.ui.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
+ var unreadDividerTimestamp by remember(chatId) { mutableStateOf(null) }
+
+ var mediaRecorder by remember { mutableStateOf(null) }
+ var audioFile by remember { mutableStateOf(null) }
+ var isRecording by remember { mutableStateOf(false) }
+ var pendingCameraUri by remember { mutableStateOf(null) }
+ var imagePreviewUrl by remember { mutableStateOf(null) }
- val 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)
+ val context = LocalContext.current
+
+ val imagePickerLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ if (uri != null) {
+ chatThreadViewModel.sendMediaMessage(chatId, currentUser.name, uri, isGroup, "image")
+ }
+ }
+
+ val cameraLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.TakePicture()
+ ) { success ->
+ val uri = pendingCameraUri
+ if (success && uri != null) {
+ chatThreadViewModel.sendMediaMessage(chatId, currentUser.name, uri, isGroup, "image")
+ }
+ pendingCameraUri = null
+ }
+
+ val audioPermissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ // Handle permission result if needed
+ }
+
+ val cameraPermissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ pendingCameraUri = createCameraImageUri(context)
+ pendingCameraUri?.let { cameraLauncher.launch(it) }
+ }
+ }
+
+ LaunchedEffect(chatId) {
+ if (chatId.isNotBlank()) {
+ if (isGroup) {
+ chatThreadViewModel.listenToGroupMessages(chatId)
+ } else {
+ chatThreadViewModel.listenToMessages(chatId)
+ }
+ }
+ }
+
+ LaunchedEffect(threadState.messages.size) {
+ if (threadState.messages.isNotEmpty()) {
+ if (unreadDividerTimestamp == null) {
+ unreadDividerTimestamp = threadState.lastReadTimestamp
+ }
+ listState.animateScrollToItem(threadState.messages.size - 1)
+ chatThreadViewModel.markChatAsRead(chatId, isGroup)
+ }
+ }
+
+ val avatarUser = sampleUser.copy(
+ name = chatState.selectedChatTitle,
+ profilePictureUrl = chatState.selectedChatPhoto
)
+
+ 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 = brandonUser.name,
- chatSubtitle = "",
- chatAvatarUser = brandonUser,
+ chatTitle = chatState.selectedChatTitle,
+ chatSubtitle = if (isGroup) "Chat grupal" else (otherUser?.activity ?: ""),
+ chatAvatarUser = avatarUser,
+ isGroup = isGroup,
+ isMuted = isMuted,
+ isOnline = !isGroup && (otherUser?.isOnline ?: chatState.selectedChatIsOnline),
+ 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 = {
- messageInput = ""
- }) {
- ChatThread(messages = messages)
- }
-}
-
-@Preview(showBackground = true, name = "4A. Hilo de Chat (1 a 1) - Demo", backgroundColor = 0xFF121212, heightDp = 800)
-@Composable
-fun ChatThreadOneOnOnePreview() {
- val brandonUser = sampleUser.copy(name = "Brandon")
+ val text = messageInput.trim()
+ if (!isRecording && text.isNotBlank()) {
+ if (isGroup) {
+ chatThreadViewModel.sendGroupMessage(chatId, currentUser.name, text)
+ } else {
+ chatThreadViewModel.sendMessage(chatId, text)
+ }
+ messageInput = ""
+ }
+ },
+ onImageClick = {
+ imagePickerLauncher.launch("image/*")
+ },
+ onCameraClick = {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
+ pendingCameraUri = createCameraImageUri(context)
+ pendingCameraUri?.let { cameraLauncher.launch(it) }
+ } else {
+ cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ },
+ onLocationClick = {
+ val lat = locationState.latitude
+ val lng = locationState.longitude
+ 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 {
+ messageInput = ""
+ 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 {
+ audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ }
+ },
+ isRecordingAudio = isRecording
+ ) {
+ 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)
+ ) {
+ itemsIndexed(threadState.messages) { index, msg ->
+ val isMine = msg.senderId == currentUser.id
+ val previousMessage = threadState.messages.getOrNull(index - 1)
+ val nextMessage = threadState.messages.getOrNull(index + 1)
+ val isSameAsPrevious = previousMessage?.senderId == msg.senderId
+ val isLastInSequence = nextMessage?.senderId != msg.senderId
+ val dividerTimestamp = unreadDividerTimestamp ?: threadState.lastReadTimestamp
+ val shouldShowNewMessages = !isMine &&
+ msg.timestamp > dividerTimestamp &&
+ threadState.messages.take(index).none { previous ->
+ previous.senderId != currentUser.id && previous.timestamp > dividerTimestamp
+ }
+ if (shouldShowNewMessages) {
+ com.appnotresponding.rumbo.ui.components.molecules.chat.ChatSeparator("Nuevos mensajes")
+ }
+ val author = threadState.messageAuthors[msg.senderId]
+ val activity = author?.activity
+ val bubbleType = if (msg.type == "location") ChatBubbleType.Location else ChatBubbleType.Regular
+ 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 museoNacional = samplePlace.copy(
- name = "Museo Nacional", openHours = emptyList(), price = "$ 40.000 COP"
- )
-
- val mockMessages = listOf(
- ChatMessageData("Hola! Vamos al Museo Nacional?", isUserMessage = true),
- ChatMessageData("Hola! De una!", isUserMessage = false),
- ChatMessageData(
- "", isUserMessage = false, type = ChatBubbleType.LiveActivity, place = museoNacional
- ),
- ChatMessageData("Iniciaste una ruta compartida", isUserMessage = false, isSeparator = true),
- ChatMessageData("Ya estoy en camino!", isUserMessage = true),
- ChatMessageData(
- "¡Ya estoy cerca!\nTe parece si nos vemos en la entrada?", isUserMessage = false
- )
- )
+ val displayName = author?.name?.takeIf { it.isNotBlank() } ?: msg.senderName
+ val seenText = if (isMine) {
+ if (isGroup) {
+ val seenCount = msg.seenBy.keys.count { it != currentUser.id }
+ if (seenCount > 0) "Visto por $seenCount" else "Enviado"
+ } else {
+ val otherHasSeen = otherUid != null && msg.seenBy[otherUid] == true
+ if (otherHasSeen) "Visto" else "Enviado"
+ }
+ } else {
+ null
+ }
+ ChatBubble(
+ modifier = Modifier.padding(top = if (isSameAsPrevious) 2.dp else 8.dp),
+ message = msg.text,
+ mediaUrl = msg.mediaUrl,
+ mediaType = msg.type,
+ isUserMessage = isMine,
+ senderName = if (!isMine && isGroup && displayName.isNotBlank()) displayName else null,
+ senderActivity = if (!isMine && isGroup) activity else null,
+ type = bubbleType,
+ timestamp = msg.timestamp,
+ seenText = seenText,
+ isLastInSequence = isLastInSequence,
+ onMediaClick = { imagePreviewUrl = it },
+ onLocationClick = onLocClick
+ )
+ }
+ }
+ }
- RumboTheme(darkTheme = true) {
- ChatThreadScreen(controller = rememberNavController())
+ imagePreviewUrl?.let { previewUrl ->
+ Dialog(
+ onDismissRequest = { imagePreviewUrl = null },
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.88f))
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ AsyncImage(
+ model = previewUrl,
+ contentDescription = "Preview de imagen",
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp)),
+ contentScale = ContentScale.Fit
+ )
+ }
+ }
+ }
}
}
-@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)
+private fun createCameraImageUri(context: android.content.Context): Uri {
+ val imageFile = File.createTempFile("chat_camera_", ".jpg", context.filesDir)
+ return FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ imageFile
)
-
- RumboTheme(darkTheme = true) {
- ChatThreadScreen(
- controller = rememberNavController()
- )
- }
}
+
+
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt
new file mode 100644
index 0000000..bf45e36
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt
@@ -0,0 +1,241 @@
+package com.appnotresponding.rumbo.ui.screens.friends
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.provider.ContactsContract
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+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.res.painterResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+import com.appnotresponding.rumbo.R
+import androidx.navigation.NavHostController
+import com.appnotresponding.rumbo.models.sampleUser
+import com.appnotresponding.rumbo.navigation.AppScreens
+import com.appnotresponding.rumbo.ui.components.atoms.RumboButton
+import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonSize
+import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle
+import com.appnotresponding.rumbo.ui.components.atoms.RumboTextField
+import com.appnotresponding.rumbo.ui.components.molecules.friends.UserSearchResultItem
+import com.appnotresponding.rumbo.ui.components.molecules.friends.FriendRequestItem
+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 ?: ""
+ val context = LocalContext.current
+
+ var searchQuery by remember { mutableStateOf("") }
+ var contactDiscoveryActive by remember { mutableStateOf(false) }
+
+ val contactsPermissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ val contacts = readContactKeys(context)
+ contactDiscoveryActive = true
+ searchQuery = ""
+ friendsViewModel.searchUsersByContacts(contacts.emails, contacts.phones)
+ }
+ }
+
+ FriendsTemplate(
+ currentUser = currentUser,
+ controller = controller
+ ) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ RumboTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = searchQuery,
+ onValueChange = {
+ searchQuery = it
+ contactDiscoveryActive = false
+ if (it.isBlank()) {
+ friendsViewModel.clearSearch()
+ } else {
+ friendsViewModel.searchUserByName(it)
+ }
+ },
+ placeholder = "Buscar por nombre...",
+ label = "Buscar usuarios"
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ RumboButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = "Buscar amigos en contactos",
+ onClick = {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
+ val contacts = readContactKeys(context)
+ contactDiscoveryActive = true
+ searchQuery = ""
+ friendsViewModel.searchUsersByContacts(contacts.emails, contacts.phones)
+ } else {
+ contactsPermissionLauncher.launch(Manifest.permission.READ_CONTACTS)
+ }
+ },
+ style = RumboButtonStyle.Secondary,
+ size = RumboButtonSize.Medium,
+ icon = painterResource(R.drawable.ic_user_add)
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (searchQuery.isNotBlank() || contactDiscoveryActive) {
+ if (friendsState.isSearching) {
+ Text(
+ text = "Buscando...",
+ 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)
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+private data class ContactKeys(
+ val emails: Set,
+ val phones: Set
+)
+
+private fun readContactKeys(context: Context): ContactKeys {
+ val emails = mutableSetOf()
+ val phones = mutableSetOf()
+
+ context.contentResolver.query(
+ ContactsContract.CommonDataKinds.Email.CONTENT_URI,
+ arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS),
+ null,
+ null,
+ null
+ )?.use { cursor ->
+ val emailIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Email.ADDRESS)
+ if (emailIndex >= 0) {
+ while (cursor.moveToNext()) {
+ cursor.getString(emailIndex)?.takeIf { it.isNotBlank() }?.let { emails.add(it) }
+ }
+ }
+ }
+
+ context.contentResolver.query(
+ ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
+ arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
+ null,
+ null,
+ null
+ )?.use { cursor ->
+ val phoneIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
+ if (phoneIndex >= 0) {
+ while (cursor.moveToNext()) {
+ cursor.getString(phoneIndex)?.takeIf { it.isNotBlank() }?.let { phones.add(it) }
+ }
+ }
+ }
+
+ return ContactKeys(emails = emails, phones = phones)
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/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..0f7c7b7 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt
@@ -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,63 @@ 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,
+ isOnline: Boolean = false,
+ onMuteClick: (() -> Unit)? = null,
+ onLeaveClick: (() -> Unit)? = null,
+ onBackClick: (() -> Unit)? = null,
messageInputValue: String = "",
onMessageInputValueChange: (String) -> Unit = {},
onSendClick: () -> Unit = {},
+ onImageClick: () -> Unit = {},
+ onCameraClick: () -> Unit = {},
+ onLocationClick: () -> Unit = {},
+ onMicClick: () -> Unit = {},
+ isRecordingAudio: Boolean = false,
content: @Composable () -> Unit
) {
Scaffold(contentWindowInsets = WindowInsets(0), topBar = {
- ChatTopBar(u = chatAvatarUser, activity = chatSubtitle)
+ ChatTopBar(
+ u = chatAvatarUser,
+ activity = chatSubtitle,
+ isGroup = isGroup,
+ isMuted = isMuted,
+ isOnline = isOnline,
+ 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,
+ onCameraClick = onCameraClick,
+ onLocationClick = onLocationClick,
+ onMicClick = onMicClick,
+ isRecordingAudio = isRecordingAudio
)
}
}) { 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..0231f44
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt
@@ -0,0 +1,367 @@
+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.MutableData
+import com.google.firebase.database.Transaction
+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(),
+ val lastReadTimestamp: Long = 0
+)
+
+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 var currentMetaListener: ValueEventListener? = null
+ private var currentMetaRef: 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) }
+ }
+ currentMetaRef?.let { ref ->
+ currentMetaListener?.let { ref.removeEventListener(it) }
+ }
+
+ val myUid = auth.currentUser?.uid ?: ""
+ val metaRef = db.getReference("chats").child(chatId)
+ currentMetaRef = metaRef
+ currentMetaListener = object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val lastRead = snapshot.child("lastReadBy").child(myUid).getValue(Long::class.java) ?: 0L
+ _uiState.update { it.copy(lastReadTimestamp = lastRead) }
+ }
+
+ override fun onCancelled(error: DatabaseError) {}
+ }
+ metaRef.addValueEventListener(currentMetaListener!!)
+
+ val ref = db.getReference("messages").child(chatId)
+ currentRef = ref
+
+ 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 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) }
+ }
+ currentMetaRef?.let { ref ->
+ currentMetaListener?.let { ref.removeEventListener(it) }
+ }
+
+ val myUid = auth.currentUser?.uid ?: ""
+ val metaRef = db.getReference("groupChats").child(placeId)
+ currentMetaRef = metaRef
+ currentMetaListener = object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val lastRead = snapshot.child("lastReadBy").child(myUid).getValue(Long::class.java) ?: 0L
+ _uiState.update { it.copy(lastReadTimestamp = lastRead) }
+ }
+
+ override fun onCancelled(error: DatabaseError) {}
+ }
+ metaRef.addValueEventListener(currentMetaListener!!)
+
+ val ref = db.getReference("groupMessages").child(placeId)
+ currentRef = ref
+
+ 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 recipientUid = participants.firstOrNull { it != myUid }
+
+ 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)
+ db.getReference("chats").child(chatId).child("lastSenderId").setValue(myUid)
+ recipientUid?.let { incrementUnreadCount("chats", chatId, it) }
+ _uiState.update { it.copy(isSending = false) }
+ }.addOnFailureListener {
+ _uiState.update { it.copy(isSending = false) }
+ }
+ }
+
+ 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)
+ db.getReference("groupChats").child(placeId).child("lastSenderId").setValue(myUid)
+ incrementGroupUnreadCounts(placeId, myUid)
+ _uiState.update { it.copy(isSending = false) }
+ }.addOnFailureListener {
+ _uiState.update { it.copy(isSending = false) }
+ }
+ }
+
+ 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)
+ db.getReference("groupChats").child(chatId).child("lastSenderId").setValue(myUid)
+ incrementGroupUnreadCounts(chatId, myUid)
+ } 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)
+ db.getReference("chats").child(chatId).child("lastSenderId").setValue(myUid)
+ chatId.split("_").firstOrNull { it != myUid }?.let { incrementUnreadCount("chats", chatId, it) }
+ }
+ _uiState.update { it.copy(isSending = false) }
+ }.addOnFailureListener {
+ _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)
+ db.getReference("groupChats").child(chatId).child("lastSenderId").setValue(myUid)
+ incrementGroupUnreadCounts(chatId, myUid)
+ } 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)
+ db.getReference("chats").child(chatId).child("lastSenderId").setValue(myUid)
+ chatId.split("_").firstOrNull { it != myUid }?.let { incrementUnreadCount("chats", chatId, it) }
+ }
+ _uiState.update { state -> state.copy(isSending = false) }
+ }.addOnFailureListener {
+ _uiState.update { state -> state.copy(isSending = false) }
+ }
+ }
+ }.addOnFailureListener {
+ _uiState.update { it.copy(isSending = false) }
+ }
+ }
+
+ fun markChatAsRead(chatId: String, isGroup: Boolean) {
+ val myUid = auth.currentUser?.uid ?: return
+ val now = System.currentTimeMillis()
+ val metaRef = db.getReference(if (isGroup) "groupChats" else "chats").child(chatId)
+ metaRef.child("lastReadBy").child(myUid).setValue(now)
+ metaRef.child("unreadCounts").child(myUid).setValue(0)
+
+ val messagesRef = db.getReference(if (isGroup) "groupMessages" else "messages").child(chatId)
+ messagesRef.get().addOnSuccessListener { snapshot ->
+ snapshot.children.forEach { child ->
+ val senderId = child.child("senderId").value as? String ?: return@forEach
+ if (senderId != myUid) {
+ child.ref.child("seenBy").child(myUid).setValue(true)
+ }
+ }
+ }
+ }
+
+ private fun incrementGroupUnreadCounts(placeId: String, myUid: String) {
+ db.getReference("groupChats").child(placeId).child("participants").get().addOnSuccessListener { snapshot ->
+ snapshot.children.mapNotNull { it.key }.filter { it != myUid }.forEach { participantUid ->
+ incrementUnreadCount("groupChats", placeId, participantUid)
+ }
+ }
+ }
+
+ private fun incrementUnreadCount(root: String, chatId: String, recipientUid: String) {
+ db.getReference(root).child(chatId).child("unreadCounts").child(recipientUid)
+ .runTransaction(object : Transaction.Handler {
+ override fun doTransaction(currentData: MutableData): Transaction.Result {
+ val current = when (val value = currentData.value) {
+ is Long -> value.toInt()
+ is Int -> value
+ else -> 0
+ }
+ currentData.value = current + 1
+ return Transaction.success(currentData)
+ }
+
+ override fun onComplete(error: DatabaseError?, committed: Boolean, currentData: DataSnapshot?) {}
+ })
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ clearUserListeners()
+ currentRef?.let { ref ->
+ currentListener?.let { ref.removeEventListener(it) }
+ }
+ currentMetaRef?.let { ref ->
+ currentMetaListener?.let { ref.removeEventListener(it) }
+ }
+ }
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt
new file mode 100644
index 0000000..0be80d6
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt
@@ -0,0 +1,310 @@
+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 selectedChatIsOnline: Boolean = false,
+ 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
+ val unreadCount = (child.child("unreadCounts").child(myUid).getValue(Int::class.java)
+ ?: child.child("unreadCounts").child(myUid).getValue(Long::class.java)?.toInt()
+ ?: 0).coerceAtLeast(0)
+
+ db.getReference("friendships").child(myUid).child(otherUid).get().addOnSuccessListener { friendshipSnap ->
+ val areFriends = friendshipSnap.exists() && friendshipSnap.value == true
+ 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,
+ isOtherUserOnline = user.isOnline,
+ lastMessage = lastMessage,
+ lastMessageTimestamp = lastTimestamp,
+ unreadCount = unreadCount
+ )
+ )
+ } 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 unreadCount = (snapshot.child("unreadCounts").child(myUid).getValue(Int::class.java)
+ ?: snapshot.child("unreadCounts").child(myUid).getValue(Long::class.java)?.toInt()
+ ?: 0).coerceAtLeast(0)
+ val mutedByMap = mutableMapOf()
+ for (muteChild in snapshot.child("mutedBy").children) {
+ val muteKey = muteChild.key ?: continue
+ mutedByMap[muteKey] = muteChild.value as? Boolean ?: false
+ }
+
+ val group = GroupChat(
+ placeId = placeId,
+ placeName = placeName,
+ lastMessage = lastMessage,
+ lastMessageTimestamp = lastTimestamp,
+ unreadCount = unreadCount,
+ 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?, isOnline: Boolean = false) {
+ _uiState.update {
+ it.copy(
+ selectedChatId = chatId,
+ selectedChatTitle = chatTitle,
+ selectedChatPhoto = photoUrl,
+ selectedChatIsOnline = isOnline,
+ isGroupChat = false
+ )
+ }
+ }
+
+ fun selectGroupChat(placeId: String, placeName: String) {
+ _uiState.update {
+ it.copy(
+ selectedChatId = placeId,
+ selectedChatTitle = placeName,
+ selectedChatPhoto = null,
+ selectedChatIsOnline = false,
+ 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..f1fbdca
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt
@@ -0,0 +1,292 @@
+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 searchUsersByContacts(emails: Set, phones: Set) {
+ val myUid = auth.currentUser?.uid ?: return
+ if (emails.isEmpty() && phones.isEmpty()) {
+ _uiState.update {
+ it.copy(
+ searchResults = emptyList(),
+ searchError = "No encontramos emails o teléfonos en tus contactos"
+ )
+ }
+ return
+ }
+
+ _uiState.update { it.copy(isSearching = true, searchError = null) }
+ val normalizedEmails = emails.map { it.lowercase().trim() }.toSet()
+ val normalizedPhones = phones.map { normalizePhone(it) }.filter { it.isNotBlank() }.toSet()
+
+ dbUsers.get().addOnSuccessListener { snapshot ->
+ val results = mutableListOf()
+ for (child in snapshot.children) {
+ val user = child.getValue(User::class.java) ?: continue
+ val userEmail = user.email.lowercase().trim()
+ val userPhone = normalizePhone(user.phone)
+ val matchesEmail = userEmail.isNotBlank() && normalizedEmails.contains(userEmail)
+ val matchesPhone = userPhone.isNotBlank() && normalizedPhones.contains(userPhone)
+ if (user.id != myUid && (matchesEmail || matchesPhone)) {
+ results.add(user)
+ }
+ }
+ _uiState.update {
+ it.copy(
+ searchResults = results.distinctBy { user -> user.id },
+ isSearching = false,
+ searchError = if (results.isEmpty()) "No encontramos amigos de Rumbo en tus contactos" else null
+ )
+ }
+ }.addOnFailureListener {
+ _uiState.update { state -> state.copy(isSearching = false, searchError = "Error al revisar contactos") }
+ }
+ }
+
+ fun addFriend(targetUid: String) {
+ val myUid = auth.currentUser?.uid ?: return
+ if (targetUid == myUid) return
+
+ // 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 normalizePhone(phone: String): String {
+ return phone.filter { it.isDigit() }.takeLast(10)
+ }
+
+ 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..6e164a9 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt
@@ -6,6 +6,7 @@ import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.database.DataSnapshot
import com.google.firebase.database.DatabaseError
import com.google.firebase.database.FirebaseDatabase
+import com.google.firebase.database.ServerValue
import com.google.firebase.database.ValueEventListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -13,7 +14,10 @@ import kotlinx.coroutines.flow.asStateFlow
class UserViewModel : ViewModel() {
private val auth = FirebaseAuth.getInstance()
- private val dbRef = FirebaseDatabase.getInstance().getReference("users")
+ private val database = FirebaseDatabase.getInstance()
+ private val dbRef = database.getReference("users")
+ private val connectedRef = database.getReference(".info/connected")
+ private var presenceListener: ValueEventListener? = null
private val _currentUserState = MutableStateFlow(null)
val currentUserState: StateFlow = _currentUserState.asStateFlow()
@@ -24,21 +28,88 @@ class UserViewModel : ViewModel() {
val uid = firebaseAuth.currentUser?.uid
if (uid != null) {
fetchUserData(uid)
+ setupPresence(uid)
} else {
_currentUserState.value = null
}
}
}
+ private fun setupPresence(uid: String) {
+ presenceListener?.let { connectedRef.removeEventListener(it) }
+ val userStatusRef = dbRef.child(uid)
+ val listener = object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ if (snapshot.getValue(Boolean::class.java) != true) return
+ userStatusRef.child("isOnline").onDisconnect().setValue(false)
+ userStatusRef.child("lastSeenAt").onDisconnect().setValue(ServerValue.TIMESTAMP)
+ userStatusRef.child("isOnline").setValue(true)
+ }
+
+ override fun onCancelled(error: DatabaseError) {}
+ }
+ presenceListener = listener
+ connectedRef.addValueEventListener(listener)
+ }
+
private fun fetchUserData(uid: String) {
+ android.util.Log.d("UserViewModel", "Starting ValueEventListener for uid: $uid")
dbRef.child(uid).addValueEventListener(object : ValueEventListener {
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)
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ presenceListener?.let { connectedRef.removeEventListener(it) }
+ auth.currentUser?.uid?.let { uid ->
+ dbRef.child(uid).child("isOnline").setValue(false)
+ dbRef.child(uid).child("lastSeenAt").setValue(ServerValue.TIMESTAMP)
+ }
+ }
}
+
diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml
new file mode 100644
index 0000000..c746152
--- /dev/null
+++ b/app/src/main/res/drawable/ic_camera.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_cancel.xml b/app/src/main/res/drawable/ic_cancel.xml
new file mode 100644
index 0000000..2539a00
--- /dev/null
+++ b/app/src/main/res/drawable/ic_cancel.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_minus.xml b/app/src/main/res/drawable/ic_minus.xml
index 88184ed..41a21cf 100644
--- a/app/src/main/res/drawable/ic_minus.xml
+++ b/app/src/main/res/drawable/ic_minus.xml
@@ -1,10 +1,9 @@
-
-
\ No newline at end of file
+
+
diff --git a/app/src/main/res/drawable/ic_recording.xml b/app/src/main/res/drawable/ic_recording.xml
new file mode 100644
index 0000000..5633b88
--- /dev/null
+++ b/app/src/main/res/drawable/ic_recording.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/outline_cancel_24.xml b/app/src/main/res/drawable/outline_cancel_24.xml
deleted file mode 100644
index 0c7e845..0000000
--- a/app/src/main/res/drawable/outline_cancel_24.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-