diff --git a/.gitignore b/.gitignore
index e5cbb64..4580ddd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,6 @@ google-services.json
# Android Profiling
*.hprof
+
+.gradle-sandbox/
+.codegraph/
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7f000cb..920075c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,6 +1,10 @@
+import java.util.Properties
+
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.google.gms.google.services)
+ alias(libs.plugins.mapsplatform.secrets.plugin)
}
android {
@@ -8,7 +12,9 @@ android {
compileSdk {
version = release(36)
}
-
+ buildFeatures {
+ buildConfig = true
+ }
defaultConfig {
applicationId = "com.appnotresponding.rumbo"
minSdk = 26
@@ -17,14 +23,26 @@ android {
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ val keystoreFile = project.rootProject.file("local.properties")
+ val properties = Properties()
+ properties.load(keystoreFile.inputStream())
+ val mapsApiKey = properties.getProperty("MAPS_API_KEY") ?: error("MAPS_API_KEY no encontrada en local.properties")
+
+ buildConfigField(
+ "String",
+ "MAPS_API_KEY",
+ "\"$mapsApiKey\""
+ )
+
+ manifestPlaceholders["MAPS_API_KEY"] = mapsApiKey
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
+ getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
)
}
}
@@ -52,6 +70,15 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.material3)
implementation(libs.androidx.compose.foundation.layout)
+ implementation(libs.firebase.auth)
+ implementation(libs.androidx.credentials)
+ implementation(libs.androidx.credentials.play.services.auth)
+ implementation(libs.androidx.security.crypto)
+ implementation(libs.firebase.database)
+ implementation(libs.firebase.storage)
+ implementation(libs.googleid)
+ implementation(libs.androidx.compose.runtime)
+ implementation(libs.firebase.messaging)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
@@ -61,4 +88,14 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.test.manifest)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
+ implementation(libs.play.services.location)
+ implementation(libs.accompanist.permissions)
+ implementation(libs.maps.compose)
+ implementation(libs.maps.compose.utils)
+ implementation(libs.play.services.maps)
+ implementation("androidx.compose.material:material-icons-extended")
+ implementation("org.osmdroid:osmdroid-android:6.1.16")
+ implementation("com.github.MKergall:osmbonuspack:6.8.0")
+ implementation("com.google.android.gms:play-services-location:21.3.0")
+ implementation("com.android.volley:volley:1.2.1")
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 33b175d..7f3c287 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,11 +1,19 @@
-
+
+
+
+
+
+
+
+
+ android:theme="@style/Theme.Rumbo"
+ android:windowSoftInputMode="adjustResize">
-
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
+
diff --git a/app/src/main/assets/places.json b/app/src/main/assets/places.json
new file mode 100644
index 0000000..69b84e3
--- /dev/null
+++ b/app/src/main/assets/places.json
@@ -0,0 +1,266 @@
+[
+ {
+ "id": "1",
+ "name": "Museo del Oro",
+ "description": "El Museo del Oro del Banco de la República es uno de los más importantes del mundo. Alberga la mayor colección de piezas de orfebrería precolombina, con más de 55.000 piezas de oro y otros materiales. Una visita obligada para conocer la historia y cultura de los pueblos indígenas colombianos.",
+ "openHours": "9:00 AM - 5:00 PM",
+ "price": "Gratis",
+ "latitude": 4.60203,
+ "longitude": -74.07203,
+ "rating": 4.8,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/museo_del_oro.jpg"
+ },
+ {
+ "id": "2",
+ "name": "Monserrate",
+ "description": "El Cerro de Monserrate es el símbolo más representativo de Bogotá. Ubicado a 3.152 metros sobre el nivel del mar, ofrece una vista panorámica espectacular de la ciudad. En su cima se encuentra el Santuario del Señor Caído, lugar de peregrinación y devoción religiosa.",
+ "openHours": "6:00 AM - 11:30 PM",
+ "price": "$26.000 COP",
+ "latitude": 4.6039,
+ "longitude": -74.0614,
+ "rating": 4.7,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/monserrate.jpg"
+ },
+ {
+ "id": "3",
+ "name": "La Candelaria",
+ "description": "La Candelaria es el centro histórico y cultural de Bogotá. Sus calles empedradas y coloridas fachadas coloniales cuentan la historia de la ciudad desde la época de la conquista. Alberga museos, teatros, universidades y la Plaza de Bolívar, corazón político del país.",
+ "openHours": "Abierto todo el día",
+ "price": "Gratis",
+ "latitude": 4.5955,
+ "longitude": -74.07354,
+ "rating": 4.5,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/la_candelaria.jpg"
+ },
+ {
+ "id": "4",
+ "name": "Plaza de Bolívar",
+ "description": "La Plaza de Bolívar es el epicentro histórico y político de Colombia. Rodeada por edificios emblemáticos como la Catedral Primada, el Capitolio Nacional y el Palacio de Justicia, esta plaza ha sido escenario de los momentos más importantes de la historia del país.",
+ "openHours": "Abierto todo el día",
+ "price": "Gratis",
+ "latitude": 4.5983,
+ "longitude": -74.0761,
+ "rating": 4.6,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/plaza_bolivar.jpg"
+ },
+ {
+ "id": "5",
+ "name": "Museo Botero",
+ "description": "El Museo Botero exhibe la donación que el maestro Fernando Botero hizo al Banco de la República. Cuenta con 123 obras del artista colombiano y 85 piezas de artistas internacionales como Picasso, Monet y Dalí. La entrada es completamente gratuita.",
+ "openHours": "9:00 AM - 7:00 PM",
+ "price": "Gratis",
+ "latitude": 4.5968,
+ "longitude": -74.0735,
+ "rating": 4.7,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/museo_botero.jpg"
+ },
+ {
+ "id": "6",
+ "name": "Jardín Botánico de Bogotá",
+ "description": "El Jardín Botánico José Celestino Mutis es un santuario verde en medio de la ciudad. Alberga más de 5.000 especies de plantas colombianas y ecosistemas representativos del país. Es ideal para familias, amantes de la naturaleza y quienes buscan un descanso del bullicio urbano.",
+ "openHours": "8:00 AM - 5:00 PM",
+ "price": "$5.000 COP",
+ "latitude": 4.6686,
+ "longitude": -74.0973,
+ "rating": 4.6,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/jardin_botanico.jpg"
+ },
+ {
+ "id": "7",
+ "name": "Parque Simón Bolívar",
+ "description": "El Parque Simón Bolívar es el pulmón verde más grande de Bogotá con más de 400 hectáreas. Es el escenario de grandes eventos culturales y musicales, incluyendo el famoso Festival Estéreo Picnic. Cuenta con lago, ciclovías, zonas deportivas y áreas de picnic.",
+ "openHours": "5:00 AM - 10:00 PM",
+ "price": "Gratis",
+ "latitude": 4.6581,
+ "longitude": -74.0935,
+ "rating": 4.5,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/parque_simon_bolivar.jpg"
+ },
+ {
+ "id": "8",
+ "name": "Museo Nacional de Colombia",
+ "description": "El Museo Nacional es el más antiguo y uno de los más importantes de Colombia. Funciona en un edificio que fue anteriormente la Penitenciaría Central conocida como 'El Panóptico'. Sus colecciones abarcan arte, historia y arqueología desde tiempos precolombinos hasta la actualidad.",
+ "openHours": "10:00 AM - 5:30 PM",
+ "price": "Gratis",
+ "latitude": 4.6141,
+ "longitude": -74.0702,
+ "rating": 4.6,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/museo_nacional.jpg"
+ },
+ {
+ "id": "9",
+ "name": "Theatron de Pelicula",
+ "description": "Theatron es considerado uno de los clubes nocturnos más grandes de América Latina. Con más de 13 ambientes temáticos y capacidad para miles de personas, es un ícono de la vida nocturna bogotana y un lugar emblemático de la comunidad LGBTQ+ de la ciudad.",
+ "openHours": "9:00 PM - 4:00 AM",
+ "price": "$40.000 COP",
+ "latitude": 4.6321,
+ "longitude": -74.0641,
+ "rating": 4.3,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/theatron.jpg"
+ },
+ {
+ "id": "10",
+ "name": "Chorro de Quevedo",
+ "description": "El Chorro de Quevedo es considerado el lugar donde se fundó Bogotá en 1538. Esta pequeña pero emblemática plazoleta en La Candelaria es punto de encuentro de artistas, músicos y bohemios. Los fines de semana cobra especial vida con presentaciones artísticas y artesanías.",
+ "openHours": "Abierto todo el día",
+ "price": "Gratis",
+ "latitude": 4.5985,
+ "longitude": -74.0783,
+ "rating": 4.4,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/chorro_quevedo.jpg"
+ },
+ {
+ "id": "11",
+ "name": "Andrés Carne de Res",
+ "description": "Andrés Carne de Res en Chía (a las afueras de Bogotá) es mucho más que un restaurante: es una experiencia cultural única. Con decoración extravagante, música en vivo, gastronomía colombiana excepcional y ambiente festivo, es uno de los destinos más icónicos de la región.",
+ "openHours": "Jueves a Domingo 12:00 PM - 3:00 AM",
+ "price": "$80.000 COP",
+ "latitude": 4.8607,
+ "longitude": -74.0291,
+ "rating": 4.7,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/andres_carne_res.jpg"
+ },
+ {
+ "id": "12",
+ "name": "Zona Rosa - Parque 93",
+ "description": "El Parque 93 y la Zona Rosa conforman el corazón gastronómico y de entretenimiento de Bogotá. Rodeado de restaurantes de alta cocina, bares, cafés y boutiques de moda, el Parque 93 es también escenario de ferias, conciertos y eventos culturales al aire libre.",
+ "openHours": "Abierto todo el día",
+ "price": "Gratis",
+ "latitude": 4.6761,
+ "longitude": -74.0479,
+ "rating": 4.5,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/parque93.jpg"
+ },
+ {
+ "id": "13",
+ "name": "Iglesia de la Candelaria",
+ "description": "La Iglesia Nuestra Señora de la Candelaria es una de las más antiguas de Bogotá, construida en el siglo XVII. Su arquitectura barroca colonial y sus retablos dorados la convierten en un tesoro del patrimonio histórico y religioso de la capital colombiana.",
+ "openHours": "7:00 AM - 7:00 PM",
+ "price": "Gratis",
+ "latitude": 4.5971,
+ "longitude": -74.0768,
+ "rating": 4.5,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/iglesia_candelaria.jpg"
+ },
+ {
+ "id": "14",
+ "name": "Planetario de Bogotá",
+ "description": "El Planetario de Bogotá es un espacio dedicado a la divulgación científica y astronómica. Ofrece espectáculos del cielo estrellado, exposiciones interactivas y talleres educativos para todas las edades. Es uno de los centros de ciencia más visitados de la ciudad.",
+ "openHours": "9:00 AM - 5:30 PM",
+ "price": "$10.000 COP",
+ "latitude": 4.6159,
+ "longitude": -74.0709,
+ "rating": 4.4,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/planetario.jpg"
+ },
+ {
+ "id": "15",
+ "name": "Cerro de Guadalupe",
+ "description": "El Cerro de Guadalupe, con su imponente figura de la Virgen Inmaculada, se eleva a 3.270 metros sobre el nivel del mar, superando al Monserrate en altura. Ofrece una vista aún más amplia de Bogotá y sus alrededores, siendo un destino de senderismo y devoción religiosa.",
+ "openHours": "6:00 AM - 6:00 PM",
+ "price": "Gratis",
+ "latitude": 4.6021,
+ "longitude": -74.0535,
+ "rating": 4.5,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/guadalupe.jpg"
+ },
+ {
+ "id": "16",
+ "name": "Mercado de la Perseverancia",
+ "description": "El Mercado de la Perseverancia es uno de los mercados tradicionales más queridos de Bogotá. Fundado en 1917, es un lugar auténtico donde se puede disfrutar de la gastronomía popular bogotana: changua, ajiaco, tamales y jugos de frutas exóticas en un ambiente colorido y animado.",
+ "openHours": "7:00 AM - 6:00 PM",
+ "price": "Gratis",
+ "latitude": 4.6279,
+ "longitude": -74.0651,
+ "rating": 4.3,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/mercado_perseverancia.jpg"
+ },
+ {
+ "id": "17",
+ "name": "Museo de Arte del Banco de la República (MAMU)",
+ "description": "El MAMU reúne una de las colecciones de arte más importantes de Colombia, con obras que van desde el periodo colonial hasta el arte contemporáneo. Sus exposiciones temporales traen artistas nacionales e internacionales de primer nivel, convirtiéndolo en referente cultural de la ciudad.",
+ "openHours": "9:00 AM - 7:00 PM",
+ "price": "Gratis",
+ "latitude": 4.5967,
+ "longitude": -74.0744,
+ "rating": 4.6,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/mamu.jpg"
+ },
+ {
+ "id": "18",
+ "name": "Maloka Centro Interactivo",
+ "description": "Maloka es el centro interactivo de ciencia y tecnología más grande de Colombia. Con más de 300 módulos interactivos, cine domo, exposiciones temáticas y programas educativos, es un espacio fascinante para descubrir el universo de la ciencia de forma divertida y participativa.",
+ "openHours": "9:00 AM - 6:00 PM",
+ "price": "$28.000 COP",
+ "latitude": 4.6576,
+ "longitude": -74.0947,
+ "rating": 4.6,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/maloka.jpg"
+ },
+ {
+ "id": "19",
+ "name": "Catedral de Sal de Zipaquirá",
+ "description": "A tan solo una hora de Bogotá, la Catedral de Sal de Zipaquirá es una de las maravillas arquitectónicas de Colombia. Construida 200 metros bajo tierra dentro de una mina de sal activa, combina fe, historia y ingeniería en un escenario absolutamente único en el mundo.",
+ "openHours": "9:00 AM - 5:30 PM",
+ "price": "$45.000 COP",
+ "latitude": 5.0228,
+ "longitude": -74.0055,
+ "rating": 4.8,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/catedral_sal.jpg"
+ },
+ {
+ "id": "23",
+ "name": "Palacio Liévano",
+ "description": "El Palacio Liévano es la sede de la Alcaldía Mayor de Bogotá y uno de los edificios más bellos de la Plaza de Bolívar. Construido a finales del siglo XIX con influencia francesa, su fachada en piedra y sus cúpulas verdes lo convierten en un ícono del patrimonio arquitectónico capitalino.",
+ "openHours": "Lunes a Viernes 8:00 AM - 5:00 PM",
+ "price": "Gratis",
+ "latitude": 4.5979,
+ "longitude": -74.0763,
+ "rating": 4.4,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/palacio_lievano.jpg"
+ },
+ {
+ "id": "24",
+ "name": "Museo del Chicó",
+ "description": "El Museo El Chicó, ubicado en una casona colonial del siglo XIX rodeada de jardines, es uno de los museos de artes decorativas más importantes de Colombia. Su colección incluye muebles, porcelanas, pinturas y objetos de las épocas colonial y republicana colombiana.",
+ "openHours": "9:00 AM - 5:00 PM",
+ "price": "$8.000 COP",
+ "latitude": 4.6731,
+ "longitude": -74.0491,
+ "rating": 4.3,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/museo_chico.jpg"
+ },
+ {
+ "id": "25",
+ "name": "Barrio La Macarena",
+ "description": "La Macarena es el barrio bohemio y gastronómico más encantador de Bogotá. Con calles tranquilas al pie de los cerros, está lleno de restaurantes creativos, galerías de arte, cafés de especialidad y tiendas de diseño independiente. Es el lugar favorito de artistas, intelectuales y viajeros.",
+ "openHours": "Abierto todo el día",
+ "price": "Gratis",
+ "latitude": 4.6141,
+ "longitude": -74.0668,
+ "rating": 4.7,
+ "reviews": [],
+ "imageUrl": "https://example.com/images/la_macarena.jpg"
+ }
+]
diff --git a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt
index e7b0e71..c686a91 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt
@@ -1,31 +1,111 @@
package com.appnotresponding.rumbo
+import android.content.Intent
+import android.location.Geocoder
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
import android.os.Bundle
+import android.os.StrictMode
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.tooling.preview.Preview
+import androidx.activity.result.ActivityResultCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.fragment.app.FragmentActivity
import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
+import com.android.volley.RequestQueue
import com.appnotresponding.rumbo.navigation.Navigation
import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import com.google.firebase.auth.FirebaseAuth
+import org.osmdroid.bonuspack.routing.OSRMRoadManager
+
+lateinit var auth: FirebaseAuth
+lateinit var geocoder: Geocoder
+lateinit var roadManager: OSRMRoadManager
+lateinit var sensorManager: SensorManager
+var lightSensor: Sensor? = null
+var isDarkTheme by mutableStateOf(false)
+
+// https://developer.android.com/reference/androidx/fragment/app/FragmentActivity
+class MainActivity : FragmentActivity(), SensorEventListener {
+
+ val requestPermission = registerForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ActivityResultCallback {}
+ )
-class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ auth = FirebaseAuth.getInstance()
+ roadManager = OSRMRoadManager(this, "ANDROID")
+ val policy =
+ StrictMode.ThreadPolicy.Builder().permitAll().build()
+ StrictMode.setThreadPolicy(policy)
+
+
+ requestPermission.launch(android.Manifest.permission.POST_NOTIFICATIONS)
+ // Inicializar sensor
+ sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
+ lightSensor = sensorManager.getDefaultSensor(Sensor.TYPE_LIGHT)
+
+ val openChat = intent.getBooleanExtra("openChat", false)
+ val senderId = intent.getStringExtra("senderId")
+ val chatId = intent.getStringExtra("chatId")
+ val senderName = intent.getStringExtra("senderName")
+ val senderPhotoUrl = intent.getStringExtra("senderPhotoUrl")
+ val isOnline = intent.getBooleanExtra("isOnline", false)
+
enableEdgeToEdge()
setContent {
// Se usa la integración de coil para mejorar el rendimiento de carga de imágenes, especialmente para listas con muchas imágenes docs: https://coil-kt.github.io/coil/network/
setSingletonImageLoaderFactory { context ->
ImageLoader.Builder(context).components {
- add(OkHttpNetworkFetcherFactory())
- }.build()
+ add(OkHttpNetworkFetcherFactory())
+ }.build()
}
- RumboTheme {
- Navigation()
+ RumboTheme(darkTheme = isDarkTheme) {
+ Navigation(
+ openChat = openChat,
+ senderId = senderId,
+ chatId = chatId,
+ senderName = senderName,
+ senderPhotoUrl = senderPhotoUrl,
+ isOnline = isOnline
+ )
}
}
}
+
+ override fun onResume() {
+ super.onResume()
+ sensorManager.registerListener(
+ this, lightSensor, SensorManager.SENSOR_DELAY_NORMAL
+ )
+ }
+
+ override fun onPause() {
+ super.onPause()
+ sensorManager.unregisterListener(this)
+ }
+
+ override fun onSensorChanged(event: SensorEvent?) {
+ if (event?.sensor?.type == Sensor.TYPE_LIGHT) {
+ val lux = event.values[0]
+ isDarkTheme = lux < 2000
+ }
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ }
}
\ 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..abbbcf8
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt
@@ -0,0 +1,23 @@
+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 placePhotoUrl: String? = null,
+ 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/dropNote.kt b/app/src/main/java/com/appnotresponding/rumbo/models/dropNote.kt
index ec0be22..5de1cb0 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/models/dropNote.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/models/dropNote.kt
@@ -1,22 +1,20 @@
package com.appnotresponding.rumbo.models
+
data class DropNote(
- val id: String,
- val user: User,
- val authorId: String = user.id,
- val authorName: String = user.name,
- val authorAvatarUrl: String? = user.profilePictureUrl,
- val public: Boolean,
- val content: String,
- val imageUrl: String?,
- val timestamp: Long,
- val latitude: Double,
- val longitude: Double
+ val id: String = "",
+ val creatorId: String = "",
+ val content: String = "",
+ val imageUrl: String? = null,
+ val timestamp: Long = 0L,
+ val latitude: Double = 0.0,
+ val longitude: Double = 0.0,
+ val public: Boolean = true
)
val sampleDropNote = DropNote(
id = "1",
- user = sampleUser,
+ creatorId = sampleUser.id,
public = true,
content = "Hello! This is a sample drop note.",
imageUrl = null,
diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt b/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt
new file mode 100644
index 0000000..26df815
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt
@@ -0,0 +1,246 @@
+
+package com.appnotresponding.rumbo.models
+
+import android.content.Context
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.ViewModel
+import androidx.security.crypto.EncryptedSharedPreferences //TOFIX
+import androidx.security.crypto.MasterKey //TOFIX
+import com.google.firebase.auth.FirebaseAuth
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.core.content.ContextCompat
+import android.util.Log
+import com.appnotresponding.rumbo.ui.utils.MyApp.Companion.fcmToken
+import com.google.firebase.Firebase
+import com.google.firebase.database.FirebaseDatabase
+import com.google.firebase.messaging.messaging
+
+// https://kotlinlang.org/docs/sealed-classes.html
+sealed class AuthResult {
+ object Idle : AuthResult()
+ object Loading : AuthResult()
+ object Success : AuthResult()
+ data class Error(val message: String) : AuthResult()
+}
+
+data class LoginState(
+ val email: String = "",
+ val password: String = "",
+ val authResult: AuthResult = AuthResult.Idle,
+ val hasBiometricCredentials: Boolean = false,
+)
+
+class LoginViewModel : ViewModel() {
+
+ private val auth = FirebaseAuth.getInstance()
+
+ private val _loginState = MutableStateFlow(LoginState())
+ val loginState = _loginState.asStateFlow()
+
+ private var encryptedPrefs: android.content.SharedPreferences? = null
+
+ //EncryptedSharedPreferences:https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences
+ //MasterKey: https://developer.android.com/reference/androidx/security/crypto/MasterKey
+ fun initPrefs(context: Context) {
+ if (encryptedPrefs != null) return
+ val appContext = context.applicationContext
+ try {
+ val masterKey = MasterKey.Builder(appContext)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ encryptedPrefs = EncryptedSharedPreferences.create(
+ appContext,
+ "rumbo_secure_prefs",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ Log.i("LoginViewModel", "EncryptedSharedPreferences inicializado correctamente")
+
+ } catch (e: Exception) {
+ Log.e(
+ "LoginViewModel",
+ "Error al inicializar EncryptedSharedPreferences, intentando limpiar",
+ e
+ )
+ try {
+ // Borra archivo corrupto
+ appContext.deleteSharedPreferences("rumbo_secure_prefs")
+
+ // Reintenta crear prefs limpias
+ val masterKey = MasterKey.Builder(appContext)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ encryptedPrefs = EncryptedSharedPreferences.create(
+ appContext,
+ "rumbo_secure_prefs",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ Log.i("LoginViewModel", "EncryptedSharedPreferences reinicializado tras limpieza")
+ } catch (ex: Exception) {
+ Log.e(
+ "LoginViewModel",
+ "Fallo total de EncryptedSharedPreferences, usando fallback no encriptado",
+ ex
+ )
+ // Fallback a SharedPreferences comunes
+ encryptedPrefs =
+ appContext.getSharedPreferences("rumbo_fallback_prefs", Context.MODE_PRIVATE)
+ }
+ }
+
+ _loginState.update {
+ it.copy(hasBiometricCredentials = hasCredentials())
+ }
+ }
+
+ fun updateEmail(newValue: String) {
+ _loginState.update { it.copy(email = newValue) }
+ }
+
+ fun updatePassword(newValue: String) {
+ _loginState.update { it.copy(password = newValue) }
+ }
+
+ fun loginWithEmailPassword() {
+ val email = _loginState.value.email
+ val password = _loginState.value.password
+ if (email.isBlank() || password.isBlank()) return
+
+ _loginState.update { it.copy(authResult = AuthResult.Loading) }
+
+ auth.signInWithEmailAndPassword(email, password).addOnSuccessListener {
+ Firebase.messaging.token.addOnSuccessListener { token ->
+ fcmToken = token
+ FirebaseDatabase.getInstance().getReference("tokens/" + auth.currentUser!!.uid)
+ .setValue(fcmToken).addOnSuccessListener {
+ Log.i("FirebaseApp", "Token guardado correctamente")
+ }.addOnFailureListener { e ->
+ Log.e("FirebaseApp", "Error guardando token: ${e.message}")
+ }
+ }
+ saveCredentials(email, password)
+ _loginState.update { it.copy(authResult = AuthResult.Success) }
+ }.addOnFailureListener { e ->
+ _loginState.update { it.copy(authResult = AuthResult.Error(e.message ?: "Error")) }
+ }
+ }
+
+ // BiometricPrompt: https://developer.android.com/training/sign-in/biometric-auth
+ // BiometricManager: https://developer.android.com/reference/androidx/biometric/BiometricManager
+ fun loginWithBiometric(activity: FragmentActivity) {
+
+ // https://developer.android.com/reference/androidx/biometric/BiometricManager#canAuthenticate(int)
+ val canAuth = BiometricManager.from(activity)
+ .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)
+
+ if (canAuth != BiometricManager.BIOMETRIC_SUCCESS) {
+ _loginState.update { it.copy(authResult = AuthResult.Error("Biometría no disponible")) }
+ return
+ }
+
+ //ContextCompat.getMainExecutor: https://developer.android.com/reference/androidx/core/content/ContextCompat#getMainExecutor(android.content.Context)
+ val executor = ContextCompat.getMainExecutor(activity)
+
+
+ val callback = object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ val creds = getCredentials()
+ if (creds != null) {
+ firebaseSignIn(creds.first, creds.second)
+ } else {
+ _loginState.update { it.copy(authResult = AuthResult.Error("No hay credenciales guardadas")) }
+ }
+ }
+
+ // https://developer.android.com/reference/androidx/biometric/BiometricPrompt#ERROR_NEGATIVE_BUTTON()
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || errorCode == BiometricPrompt.ERROR_USER_CANCELED) {
+ _loginState.update { it.copy(authResult = AuthResult.Idle) }
+ } else {
+ _loginState.update { it.copy(authResult = AuthResult.Error(errString.toString())) }
+ }
+ }
+
+ override fun onAuthenticationFailed() {}
+ }
+
+ // https://developer.android.com/reference/androidx/biometric/BiometricPrompt.PromptInfo.Builder
+ val promptInfo = BiometricPrompt.PromptInfo.Builder().setTitle("Acceder a Rumbo")
+ .setSubtitle("Confirma tu identidad").setNegativeButtonText("Usar contraseña").build()
+
+ BiometricPrompt(activity, executor, callback).authenticate(promptInfo)
+ }
+
+ private fun firebaseSignIn(email: String, password: String) {
+ _loginState.update { it.copy(authResult = AuthResult.Loading) }
+ auth.signInWithEmailAndPassword(email, password).addOnSuccessListener {
+ Firebase.messaging.token.addOnSuccessListener { token ->
+ fcmToken = token
+ FirebaseDatabase.getInstance().getReference("tokens/" + auth.currentUser!!.uid)
+ .setValue(fcmToken).addOnSuccessListener {
+ Log.i("FirebaseApp", "Token guardado correctamente")
+ }.addOnFailureListener { e ->
+ Log.e("FirebaseApp", "Error guardando token: ${e.message}")
+ }
+ }
+ _loginState.update { it.copy(authResult = AuthResult.Success) }
+ }.addOnFailureListener { e ->
+ clearCredentials()
+ _loginState.update {
+ it.copy(
+ hasBiometricCredentials = false,
+ authResult = AuthResult.Error("Sesión expirada, ingresa tu contraseña")
+ )
+ }
+ }
+ }
+
+ //https://developer.android.com/reference/android/content/SharedPreferences.Editor
+ private fun saveCredentials(email: String, password: String) {
+ val prefs = encryptedPrefs
+ if (prefs != null) {
+ val success =
+ prefs.edit().putString("email", email).putString("password", password).commit()
+ if (success) {
+ Log.i("LoginViewModel", "Credenciales guardadas con éxito")
+ _loginState.update { it.copy(hasBiometricCredentials = true) }
+ } else {
+ Log.e("LoginViewModel", "Error al escribir credenciales con commit")
+ _loginState.update { it.copy(hasBiometricCredentials = false) }
+ }
+ } else {
+ Log.e("LoginViewModel", "Imposible guardar credenciales: encryptedPrefs es null")
+ _loginState.update { it.copy(hasBiometricCredentials = false) }
+ }
+ }
+
+ private fun getCredentials(): Pair? {
+ val prefs = encryptedPrefs ?: return null
+ val email = prefs.getString("email", null)
+ val password = prefs.getString("password", null)
+ return if (!email.isNullOrBlank() && !password.isNullOrBlank()) Pair(
+ email, password
+ ) else null
+ }
+
+ private fun hasCredentials(): Boolean {
+ val prefs = encryptedPrefs ?: return false
+ val email = prefs.getString("email", null)
+ val password = prefs.getString("password", null)
+ return !email.isNullOrBlank() && !password.isNullOrBlank()
+ }
+
+ private fun clearCredentials() {
+ encryptedPrefs?.edit()?.clear()?.commit()
+ _loginState.update { it.copy(hasBiometricCredentials = false) }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/place.kt b/app/src/main/java/com/appnotresponding/rumbo/models/place.kt
index 643ccb1..4251379 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/models/place.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/models/place.kt
@@ -1,27 +1,29 @@
package com.appnotresponding.rumbo.models
data class Place(
- val id: String,
- val name: String,
- val description: String,
- val openHours: String,
- val price: String,
- val latitude: Double,
- val longitude: Double,
- val rating: Float,
- val reviews: List,
- val imageUrl: String
+ val id: String = "",
+ val name: String = "",
+ val address: String = "",
+ val description: String? = null,
+ val openHours: List? = null,
+ val price: String? = null,
+ val latitude: Double = 0.0,
+ val longitude: Double = 0.0,
+ val rating: Double? = null,
+ val reviews: List = emptyList(),
+ val imageUrl: String? = null
)
val samplePlace = Place(
id = "1",
name = "Museo del Oro",
description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed consectetur bibendum diam a interdum. Donec tempus fringilla auctor. Vivamus imperdiet, orci eu consectetur placerat.",
- openHours = "9:00 AM - 5:00 PM",
+ openHours = listOf("9:00 AM - 5:00 PM", ""),
price = "Gratis",
latitude = 4.6018,
longitude = -74.0719,
- rating = 4.5f,
+ rating = 4.5,
reviews = emptyList(),
- imageUrl = ""
+ imageUrl = "",
+ address = ""
)
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt
new file mode 100644
index 0000000..a9fc38b
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt
@@ -0,0 +1,12 @@
+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 previewedPlace: Place? = null,
+ val searchQuery: String = "",
+ val focusLocation: LatLng? = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/registerViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/models/registerViewModel.kt
new file mode 100644
index 0000000..76f96c0
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/models/registerViewModel.kt
@@ -0,0 +1,208 @@
+
+package com.appnotresponding.rumbo.models
+
+
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.database.FirebaseDatabase
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+import com.google.firebase.storage.FirebaseStorage
+
+import android.content.Context
+import android.util.Log
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import com.appnotresponding.rumbo.ui.utils.MyApp.Companion.fcmToken
+import com.google.firebase.Firebase
+import com.google.firebase.messaging.messaging
+
+data class RegisterState(
+ val name: String = "",
+ val lastname: String = "",
+ val phone: String = "",
+ val email: String = "",
+ val password: String = "",
+ val photoUri: Uri? = null,
+ val isLoading: Boolean = false,
+ val firebaseError: String = ""
+)
+
+class RegisterViewModel : ViewModel() {
+ private val auth = FirebaseAuth.getInstance()
+ private val dbRef = FirebaseDatabase.getInstance().getReference("users")
+
+ private val _registerState = MutableStateFlow(RegisterState())
+ val registerState: StateFlow = _registerState.asStateFlow()
+
+ private var encryptedPrefs: android.content.SharedPreferences? = null
+
+ private fun initPrefs(context: Context) {
+ if (encryptedPrefs != null) return
+ val appContext = context.applicationContext
+ try {
+ val masterKey = MasterKey.Builder(appContext)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ encryptedPrefs = EncryptedSharedPreferences.create(
+ appContext,
+ "rumbo_secure_prefs",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ } catch (e: Exception) {
+ try {
+ appContext.deleteSharedPreferences("rumbo_secure_prefs")
+ val masterKey = MasterKey.Builder(appContext)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+
+ encryptedPrefs = EncryptedSharedPreferences.create(
+ appContext,
+ "rumbo_secure_prefs",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ } catch (ex: Exception) {
+ encryptedPrefs = appContext.getSharedPreferences("rumbo_fallback_prefs", Context.MODE_PRIVATE)
+ }
+ }
+ }
+
+ private fun saveCredentials(email: String, password: String) {
+ encryptedPrefs?.edit()
+ ?.putString("email", email)
+ ?.putString("password", password)
+ ?.commit()
+ }
+
+ fun updateName(name: String) {
+ _registerState.update { it.copy(name = name) }
+ }
+
+ fun updateLastname(lastname: String) {
+ _registerState.update { it.copy(lastname = lastname) }
+ }
+
+ fun updatePhone(phone: String) {
+ _registerState.update { it.copy(phone = phone) }
+ }
+
+ fun updateEmail(email: String) {
+ _registerState.update { it.copy(email = email) }
+ }
+
+ fun updatePassword(password: String) {
+ _registerState.update { it.copy(password = password) }
+ }
+
+ fun updatePhoto(uri: Uri?) {
+ _registerState.update { it.copy(photoUri = uri) }
+ }
+
+ fun isFormValid(): Boolean {
+ val state = _registerState.value
+ val emailRegex = Regex("""^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$""")
+ val phoneRegex = Regex("^\\+\\d{10,14}$")
+ val passwordRegex = Regex("""^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$""")
+
+ return state.name.isNotBlank() &&
+ state.lastname.isNotBlank() &&
+ phoneRegex.matches(state.phone) &&
+ emailRegex.matches(state.email) &&
+ passwordRegex.matches(state.password)
+ }
+
+ fun register(context: Context, onSuccess: () -> Unit) {
+ val state = _registerState.value
+ if (!isFormValid()) return
+
+ initPrefs(context)
+ _registerState.update { it.copy(isLoading = true, firebaseError = "") }
+
+ auth.createUserWithEmailAndPassword(state.email, state.password)
+ .addOnCompleteListener { task ->
+ if (task.isSuccessful) {
+ val userId = auth.currentUser?.uid
+ if (userId != null) {
+ if (state.photoUri != null) {
+ val storageRef = FirebaseStorage.getInstance("gs://rumbowapp.firebasestorage.app")
+ .getReference("profile_pictures/$userId.jpg")
+ storageRef.putFile(state.photoUri)
+ .addOnSuccessListener {
+ storageRef.downloadUrl.addOnSuccessListener { downloadUrl ->
+ saveUserToDatabase(userId, downloadUrl.toString(), context, onSuccess)
+ }.addOnFailureListener { e ->
+ _registerState.update {
+ it.copy(isLoading = false, firebaseError = e.message ?: "Error al obtener URL de descarga")
+ }
+ }
+ }
+ .addOnFailureListener { e ->
+ _registerState.update {
+ it.copy(isLoading = false, firebaseError = e.message ?: "Error al subir imagen")
+ }
+ }
+ } else {
+ saveUserToDatabase(userId, null, context, onSuccess)
+ }
+ } else {
+ _registerState.update { it.copy(isLoading = false, firebaseError = "Error al obtener ID de usuario") }
+ }
+ } else {
+ _registerState.update {
+ it.copy(isLoading = false, firebaseError = task.exception?.message ?: "Error al registrar usuario")
+ }
+ }
+ }
+ }
+
+ private fun saveUserToDatabase(userId: String, photoUrl: String?, context: Context, onSuccess: () -> Unit) {
+ val state = _registerState.value
+ val newUser = User(
+ id = userId,
+ name = state.name,
+ lastname = state.lastname,
+ email = state.email,
+ phone = state.phone,
+ profilePictureUrl = photoUrl
+ )
+ dbRef.child(userId).setValue(newUser)
+ .addOnCompleteListener { dbTask ->
+ if (dbTask.isSuccessful) {
+ auth.signInWithEmailAndPassword(state.email, state.password)
+ .addOnCompleteListener { signInTask ->
+ _registerState.update { it.copy(isLoading = false) }
+ Firebase.messaging.token.addOnSuccessListener { token ->
+ fcmToken = token
+ FirebaseDatabase.getInstance().getReference("tokens/" + auth.currentUser!!.uid)
+ .setValue(fcmToken).addOnSuccessListener {
+ Log.i("FirebaseApp", "Token guardado correctamente")
+ }.addOnFailureListener { e ->
+ Log.e("FirebaseApp", "Error guardando token: ${e.message}")
+ }
+ }
+ if (signInTask.isSuccessful) {
+ saveCredentials(state.email, state.password)
+ onSuccess()
+ } else {
+ _registerState.update {
+ it.copy(firebaseError = signInTask.exception?.message ?: "Error al iniciar sesión")
+ }
+ }
+ }
+ } else {
+ _registerState.update {
+ it.copy(isLoading = false, firebaseError = dbTask.exception?.message ?: "Error al guardar en base de datos")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/review.kt b/app/src/main/java/com/appnotresponding/rumbo/models/review.kt
index 2cd805a..166fbc2 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/models/review.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/models/review.kt
@@ -1,13 +1,14 @@
package com.appnotresponding.rumbo.models
data class Review(
- val id: String,
- val user: User,
+ val id: String = "",
+ val user: User = User(),
val authorName: String = user.name,
val authorProfilePhotoUrl: String? = user.profilePictureUrl,
- val rating: Float,
- val text: String,
- val time: Long
+ val rating: Float = 0f,
+ val text: String = "",
+ val photoUrl: String? = null,
+ val time: Long = 0L
)
val sampleReview = Review(
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 9e2935e..99c6817 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/models/user.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/models/user.kt
@@ -1,17 +1,31 @@
+
package com.appnotresponding.rumbo.models
data class User(
- val id: String,
- val name: String,
- val email: String,
- val phone: String,
- val profilePictureUrl: String? = null
+ val id: String = "",
+ val name: String = "",
+ val lastname: String = "",
+ val email: String = "",
+ val phone: String = "",
+ val latitude: Double = 0.0,
+ val longitude: Double = 0.0,
+ val altitude: Double = 0.0,
+ val profilePictureUrl: String? = null,
+ val sharingLocation: Boolean = false,
+ val activity: String? = null,
+ val isOnline: Boolean = false,
+ val lastSeenAt: Long = 0
)
val sampleUser = User(
id = "1",
- name = "John Doe",
+ name = "John",
+ lastname = "Doe",
email = "john.doe@mail.com",
phone = "+1234567890",
- profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg"
-)
\ No newline at end of file
+ latitude = 0.0,
+ longitude = 0.0,
+ altitude = 0.0,
+ profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg",
+ sharingLocation = false
+)
diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/visitedPlace.kt b/app/src/main/java/com/appnotresponding/rumbo/models/visitedPlace.kt
new file mode 100644
index 0000000..6594f2a
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/models/visitedPlace.kt
@@ -0,0 +1,12 @@
+package com.appnotresponding.rumbo.models
+
+data class VisitedPlace(
+ val placeId: String = "",
+ val placeName: String = "",
+ val address: String = "",
+ val latitude: Double = 0.0,
+ val longitude: Double = 0.0,
+ val imageUrl: String? = null,
+ val city: String = "",
+ val visitedAt: Long = 0L
+)
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 24d1dbe..49cdc54 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt
@@ -1,22 +1,35 @@
package com.appnotresponding.rumbo.navigation
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
-import com.appnotresponding.rumbo.ui.components.organisms.chat.ChatPreviewData
-import com.appnotresponding.rumbo.ui.components.organisms.chat.ChatThread
-import com.appnotresponding.rumbo.ui.screens.auth.LoginScreen
+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.profile.ProfileScreen
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 com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel
+import com.appnotresponding.rumbo.ui.viewModel.UserViewModel
-enum class AppScreens{
+val placesViewModel: PlacesViewModel = PlacesViewModel()
+val chatViewModel: ChatViewModel = ChatViewModel()
+val chatThreadViewModel: ChatThreadViewModel = ChatThreadViewModel()
+val friendsViewModel: FriendsViewModel = FriendsViewModel()
+
+enum class AppScreens {
Splash,
LogIn,
SignUp,
@@ -25,39 +38,76 @@ enum class AppScreens{
ChatThread,
Plan,
Itinerary,
- OnBoarding
+ OnBoarding,
+ Friends,
+ Profile
}
@Composable
-fun Navigation(){
+fun Navigation(
+ openChat: Boolean = false,
+ senderId: String? = null,
+ chatId: String? = null,
+ senderName: String? = null,
+ senderPhotoUrl: String? = null,
+ isOnline: Boolean = false,
+ locationViewModel: UserLocationViewModel = viewModel(),
+ userViewModel: UserViewModel = viewModel(),
+) {
val navController = rememberNavController()
- NavHost(navController=navController, startDestination = AppScreens.Splash.name){
- composable (route = AppScreens.Splash.name){
+
+ LaunchedEffect(openChat, chatId) {
+
+ if (
+ openChat &&
+ !chatId.isNullOrBlank() &&
+ !senderName.isNullOrBlank()
+ ) {
+
+ chatViewModel.selectDirectChat(
+ chatId = chatId,
+ chatTitle = senderName,
+ photoUrl = senderPhotoUrl ?: "",
+ isOnline = isOnline
+ )
+
+ navController.navigate(AppScreens.ChatThread.name)
+ }
+ }
+
+ NavHost(navController = navController, startDestination = AppScreens.Splash.name) {
+ composable(route = AppScreens.Splash.name) {
SplashScreen(navController)
}
- composable(route = AppScreens.LogIn.name){
- LoginScreen(navController)
+ 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)
+ composable(route = AppScreens.Map.name) {
+ MapScreen(navController, placesViewModel, locationViewModel, userViewModel, friendsViewModel)
}
- composable (route = AppScreens.Chat.name) {
- ChatListScreen(navController)
+ 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){
- PlanScreen(navController)
+ composable(route = AppScreens.Plan.name) {
+ PlanScreen(navController, placesViewModel, locationViewModel, userViewModel)
}
- composable(route = AppScreens.Itinerary.name){
- ItineraryScreen(navController)
+ 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)
+ }
+ composable(route = AppScreens.Profile.name) {
+ ProfileScreen(navController, userViewModel)
+ }
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/Avatar.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/Avatar.kt
index 9bd560f..70af68b 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/Avatar.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/Avatar.kt
@@ -10,10 +10,12 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -27,10 +29,13 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
+import coil3.request.ImageRequest
import com.appnotresponding.rumbo.R
import com.appnotresponding.rumbo.models.User
import com.appnotresponding.rumbo.models.sampleUser
import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import androidx.compose.ui.platform.LocalContext
+import coil3.request.allowHardware
//Tamaños predefinidos para el Avatar, con tamaños de texto asociados para las iniciales
enum class AvatarSize(val size: Dp) {
@@ -59,12 +64,24 @@ fun Avatar(
borderColor: Color = MaterialTheme.colorScheme.outline,
isOnline: Boolean = false
) {
- val pfp = user?.profilePictureUrl
+ val context = LocalContext.current
+ val avatarUrl = user?.profilePictureUrl?.takeIf { it.isNotBlank() }
val initials = user?.name
// Extraer las iniciales para mostrar, limitando a 2 caracteres y convirtiendo a mayúsculas
val displayInitials = initials?.trim()?.take(2)?.uppercase()?.ifBlank { null }
+ val imageRequest: ImageRequest? = remember(avatarUrl) {
+ avatarUrl?.let {
+ ImageRequest.Builder(context)
+ .data(it)
+ .allowHardware(false)
+ .build()
+ }
+ }
+
// Tamaño del indicador online proporcional al tamaño del avatar
+ val onlineBorderColor = if (isOnline) Color(0xFF4CAF50) else borderColor
+ val onlineBorderWidth = if (isOnline && borderWidth == 0.dp) 2.dp else borderWidth
val indicatorSize = when (size) {
AvatarSize.Small -> 10.dp
AvatarSize.Medium -> 14.dp
@@ -90,26 +107,20 @@ fun Avatar(
Box(
modifier = Modifier
.fillMaxSize()
- .background(color = backgroundColor, shape = CircleShape)
- .then(
- if (borderWidth > 0.dp) {
- Modifier.border(borderWidth, borderColor, CircleShape)
- } else {
- Modifier
- }
- ), contentAlignment = Alignment.Center
+ .background(color = backgroundColor, shape = CircleShape),
+ contentAlignment = Alignment.Center
) {
//Verificar si hay foto de perfil
when {
- pfp != null -> {
+ imageRequest != null -> {
SubcomposeAsyncImage(
- model = pfp,
+ model = imageRequest,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
- error = {
+ loading = {
Image(
painter = painterResource(R.drawable.ic_user),
contentDescription = contentDescription,
@@ -119,20 +130,69 @@ fun Avatar(
.padding(size.size * 0.2f)
)
},
+ error = {
+ if (displayInitials != null) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = displayInitials,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ style = initialsSize,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ } else {
+ Image(
+ painter = painterResource(R.drawable.ic_user),
+ contentDescription = contentDescription,
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer),
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(size.size * 0.2f)
+ )
+ }
+ },
success = {
SubcomposeAsyncImageContent()
})
}
displayInitials != null -> {
- Text(
- text = displayInitials,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- style = initialsSize,
- fontWeight = FontWeight.Bold
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = displayInitials,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ style = initialsSize,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+
+ else -> {
+ Image(
+ painter = painterResource(R.drawable.ic_user),
+ contentDescription = contentDescription,
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer),
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(size.size * 0.2f)
)
}
}
+
+ // Dibuja el borde sobre la imagen o el contenido para asegurar que no quede tapado
+ if (onlineBorderWidth > 0.dp) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .border(onlineBorderWidth, onlineBorderColor, CircleShape)
+ )
+ }
}
// Indicador de online
@@ -141,6 +201,7 @@ fun Avatar(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(indicatorSize)
+ .sizeIn(minWidth = indicatorSize, minHeight = indicatorSize)
.background(Color(0xFF4CAF50), CircleShape)
.border(indicatorBorderWidth, MaterialTheme.colorScheme.surface, CircleShape)
)
@@ -204,4 +265,4 @@ private fun AvatarDarkPreview() {
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/Rating.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/Rating.kt
index 7679226..ac08284 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/Rating.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/Rating.kt
@@ -65,8 +65,8 @@ fun RumboRatingStar(
.size(starSize)
.then(
if (onRatingChanged != null) {
- Modifier.clickable { onRatingChanged(i.toFloat()) }
- } else Modifier),
+ Modifier.clickable { onRatingChanged(i.toFloat()) }
+ } else Modifier),
tint = tint)
}
}
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
new file mode 100644
index 0000000..7f126af
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt
@@ -0,0 +1,127 @@
+package com.appnotresponding.rumbo.ui.components.atoms
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Person
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.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
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+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
+
+@Composable
+fun UserProfileBubble(
+ user: User,
+ bubbleSize: Dp = 48.dp,
+ onImageLoaded: (() -> Unit)? = null,
+ preloadedBitmap: ImageBitmap? = null
+) {
+ val context = LocalContext.current
+
+ val imageRequest = remember(user.profilePictureUrl, context) {
+ ImageRequest.Builder(context)
+ .data(user.profilePictureUrl)
+ .allowHardware(false)
+ .build()
+ }
+
+ var imageLoadFailed by remember(user.profilePictureUrl) { mutableStateOf(false) }
+
+ Box(
+ modifier = Modifier
+ .size(bubbleSize)
+ .clip(CircleShape)
+ .border(1.dp, Color.White, CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest),
+ contentAlignment = Alignment.Center
+ ) {
+ when {
+ preloadedBitmap != null -> {
+ Image(
+ bitmap = preloadedBitmap,
+ contentDescription = "Foto de perfil de ${user.name}",
+ modifier = Modifier
+ .size(bubbleSize)
+ .clip(CircleShape),
+ contentScale = ContentScale.Crop
+ )
+ LaunchedEffect(preloadedBitmap) {
+ onImageLoaded?.invoke()
+ }
+ }
+
+ !user.profilePictureUrl.isNullOrEmpty() && !imageLoadFailed -> {
+ AsyncImage(
+ model = imageRequest,
+ contentDescription = "Foto de perfil de ${user.name}",
+ modifier = Modifier
+ .size(bubbleSize)
+ .clip(CircleShape),
+ contentScale = ContentScale.Crop,
+ onSuccess = { onImageLoaded?.invoke() },
+ onError = { imageLoadFailed = true }
+ )
+ }
+
+ else -> {
+ if (!user.profilePictureUrl.isNullOrEmpty() && imageLoadFailed) {
+ Icon(
+ modifier = Modifier.size(bubbleSize * 0.5f),
+ painter = painterResource(R.drawable.ic_user),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ } else {
+ Text(
+ text = user.name.firstOrNull()?.uppercase() ?: "U",
+ fontSize = (bubbleSize.value * 0.4f).sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Clip
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun UserProfileBubblePreview() {
+ RumboTheme(darkTheme = true) {
+ UserProfileBubble(
+ user = sampleUser,
+ )
+ }
+}
\ No newline at end of file
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 5ba047e..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(
@@ -269,7 +473,7 @@ fun ChatBubble(
color = contentColor
)
Text(
- text = place.price,
+ text = place.price ?: "No hay",
style = MaterialTheme.typography.labelMedium,
color = contentColor.copy(alpha = 0.7f)
)
@@ -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/ChatBubble.kt~ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt~
deleted file mode 100644
index 7c58b6a..0000000
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt~
+++ /dev/null
@@ -1,350 +0,0 @@
-package com.appnotresponding.rumbo.ui.components.molecules.chat
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-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.aspectRatio
-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.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.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.R
-import com.appnotresponding.rumbo.models.Place
-import com.appnotresponding.rumbo.models.samplePlace
-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
-
-//TODO: Separator Component
-
-enum class ChatBubbleType {
- Regular, Location, LiveActivity
-}
-
-/**
- * Componente que representa un mensaje en el chat, con soporte para texto e imagen.
- *
- * @param message El texto del mensaje.
- * @param messageImage Una imagen opcional asociada al mensaje.
- * @param isUserMessage Indica si el mensaje es del usuario o de otro participante.
- * @param senderName El nombre del remitente, opcional para mensajes del usuario.
- * @param type El tipo de burbuja de chat (Regular, Location, LiveActivity), que determina el diseño y contenido mostrado.
- * @param place El objeto [Place] asociado al mensaje, requerido cuando el tipo es [ChatBubbleType.LiveActivity].
- */
-@Composable
-fun ChatBubble(
- message: String,
- messageImage: ImageRequest? = null,
- isUserMessage: Boolean,
- senderName: String? = null,
- type: ChatBubbleType = ChatBubbleType.Regular,
- place: Place? = null
-) {
- val horizontalAlignment = if (isUserMessage) {
- Alignment.End
- } else {
- Alignment.Start
- }
-
- val backgroundColor = if (isUserMessage) {
- MaterialTheme.colorScheme.secondary
- } else {
- MaterialTheme.colorScheme.primary
- }
-
- val contentColor = if (isUserMessage) {
- MaterialTheme.colorScheme.onSecondary
- } else {
- MaterialTheme.colorScheme.onPrimary
- }
-
- val bubbleAlignment = if (isUserMessage) {
- Arrangement.End
- } else {
- Arrangement.Start
- }
-
- when (type) {
- ChatBubbleType.Regular -> {
- Row(
- modifier = Modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth(.75f)
- .padding(8.dp)
- .background(backgroundColor, MaterialTheme.shapes.large),
- horizontalAlignment = horizontalAlignment,
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
-
- Column(
- modifier = Modifier.padding(12.dp),
- horizontalAlignment = horizontalAlignment,
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- if (senderName != null) {
- Text(
- text = senderName,
- style = MaterialTheme.typography.labelLarge,
- color = contentColor
- )
- }
-
- if (messageImage != null) {
- AsyncImage(
- modifier = Modifier.clip(MaterialTheme.shapes.medium),
- model = messageImage,
- contentDescription = null
- )
- }
- Text(
- text = message,
- style = MaterialTheme.typography.bodyMedium,
- color = contentColor
- )
- }
- }
- }
-
- }
-
- ChatBubbleType.Location -> {
- Row(
- modifier = Modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth(.75f)
- .padding(8.dp)
- .background(backgroundColor, MaterialTheme.shapes.large),
- horizontalAlignment = horizontalAlignment,
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
-
- Column(
- modifier = Modifier.padding(12.dp),
- horizontalAlignment = horizontalAlignment,
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- if (senderName != null) {
- Text(
- text = senderName,
- style = MaterialTheme.typography.labelLarge,
- color = contentColor
- )
- }
-
- Text(
- text = "Ubicación compartida",
- style = MaterialTheme.typography.labelMedium,
- color = contentColor,
- fontStyle = FontStyle.Italic
- )
-
- //Location Preview Image
- AsyncImage(
- modifier = Modifier
- .clip(MaterialTheme.shapes.medium)
- .fillMaxWidth(),
- model = ImageRequest.Builder(LocalContext.current)
- .data(R.drawable.img_map).build(),
- contentDescription = null
- )
- }
- }
- }
- }
-
- ChatBubbleType.LiveActivity -> {
- if (place != null) {
- Row(
- modifier = Modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth(.75f)
- .padding(8.dp)
- .background(backgroundColor, MaterialTheme.shapes.large),
- horizontalAlignment = horizontalAlignment,
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
-
- Column(
- modifier = Modifier.padding(12.dp),
- horizontalAlignment = horizontalAlignment,
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- if (senderName != null) {
- Text(
- text = senderName,
- style = MaterialTheme.typography.labelLarge,
- color = contentColor
- )
- }
-
- Text(
- text = "Únete a mi ruta compartida",
- style = MaterialTheme.typography.titleSmall,
- color = contentColor,
- )
-
- Row() {
- Box(
- modifier = Modifier
- .weight(3f)
- .aspectRatio(1f)
- .clip(MaterialTheme.shapes.large)
- .background(MaterialTheme.colorScheme.secondaryContainer),
- contentAlignment = Alignment.Center
- ) {
- SubcomposeAsyncImage(
- model = place.imageUrl,
- contentDescription = "Imagen de ${place.name}",
- contentScale = ContentScale.Crop,
- error = {
- Image(
- painter = painterResource(R.drawable.ic_picture),
- contentDescription = null,
- colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondaryContainer)
- )
- })
- }
-
- Column(
- modifier = Modifier
- .weight(7f)
- .padding(start = 8.dp),
- verticalArrangement = Arrangement.Center
- ) {
- Text(
- text = place.name,
- style = MaterialTheme.typography.labelLarge,
- color = contentColor
- )
- Text(
- text = place.price,
- style = MaterialTheme.typography.labelMedium,
- color = contentColor.copy(alpha = 0.7f)
- )
- }
- }
-
- RumboButton(
- modifier = Modifier.fillMaxWidth(),
- text = "Unirse",
- onClick = { /*TODO: Implementar acción de unirse a la ruta*/ },
- style = RumboButtonStyle.Secondary,
- size = RumboButtonSize.Small,
- icon = painterResource(R.drawable.ic_user_add)
- )
- }
- }
- }
- }
- }
- }
-}
-
-
-@Composable
-private fun ChatBubblePreviewContent() {
- val context = LocalContext.current
- val placeholderImage = ImageRequest.Builder(context).data(R.drawable.img_mock).build()
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- // Regular - received with image
- ChatBubble(
- message = "¡Hola! ¿Cómo estás?",
- messageImage = placeholderImage,
- isUserMessage = false,
- senderName = "Carlos",
- type = ChatBubbleType.Regular
- )
- // Regular - sent with image
- ChatBubble(
- message = "¡Todo bien! ¿Y tú?",
- messageImage = placeholderImage,
- isUserMessage = true,
- senderName = null,
- type = ChatBubbleType.Regular
- )
- // Regular - text only
- ChatBubble(
- message = "Perfecto, nos vemos en el punto de encuentro 🚗",
- isUserMessage = false,
- senderName = "Carlos",
- type = ChatBubbleType.Regular
- )
- // Location - received
- ChatBubble(
- message = "",
- isUserMessage = false,
- senderName = "Carlos",
- type = ChatBubbleType.Location
- )
- // Location - sent
- ChatBubble(
- message = "", isUserMessage = true, type = ChatBubbleType.Location
- )
- // LiveActivity - received
- ChatBubble(
- message = "",
- isUserMessage = false,
- senderName = "Carlos",
- type = ChatBubbleType.LiveActivity,
- place = samplePlace
- )
- // LiveActivity - sent
- ChatBubble(
- message = "",
- isUserMessage = true,
- type = ChatBubbleType.LiveActivity,
- place = samplePlace
- )
- }
-}
-
-@Preview(showBackground = true, name = "ChatBubble - Light", heightDp = 2000)
-
-@Composable
-private fun ChatBubbleLightPreview() {
- RumboTheme(darkTheme = false) {
- ChatBubblePreviewContent()
- }
-}
-
-@Preview(
- showBackground = true, name = "ChatBubble - Dark", backgroundColor = 0xFF1E1E1E, heightDp = 2000
-)
-
-@Composable
-private fun ChatBubbleDarkPreview() {
- RumboTheme(darkTheme = true) {
- ChatBubblePreviewContent()
- }
-}
-
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..ad19e1d 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
@@ -16,6 +16,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -41,7 +42,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
@@ -62,18 +65,28 @@ fun ChatListItem(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
- Box {
- Avatar(user = user)
- }
+ // Borde verde cuando el usuario está en línea
+ Avatar(
+ user = user,
+ isOnline = isOnline,
+ borderWidth = if (isOnline) 2.dp else 0.dp,
+ borderColor = if (isOnline) Color(0xFF4CAF50) else MaterialTheme.colorScheme.outline
+ )
Column(modifier = Modifier.weight(1f)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
Text(
text = user.name,
style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.onSurface
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f, fill = false)
)
- if (status != null) {
+ if (status != null && isOnline) {
Text(
text = " · ",
style = MaterialTheme.typography.titleSmall,
@@ -83,10 +96,12 @@ fun ChatListItem(
text = status,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f, fill = false)
)
}
}
- Box(modifier = Modifier.padding(top = 4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -101,23 +116,43 @@ fun ChatListItem(
modifier = Modifier.weight(1f)
)
- if (timestamp.isNotEmpty()) {
+ }
+ }
+ 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 = timestamp,
+ text = if (unreadCount > 99) "99+" else unreadCount.toString(),
style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- } else if (hasUnread) {
- Box(
- modifier = Modifier
- .size(8.dp)
- .background(
- color = MaterialTheme.colorScheme.onSurface, shape = CircleShape
- )
+ 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..95fbcab 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
@@ -45,8 +45,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 +59,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 +127,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 +203,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..83d0527
--- /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
+ )
+ }
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ 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/itinerary/ItineraryItemCard.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/itinerary/ItineraryItemCard.kt
index be8d2fc..a030046 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/itinerary/ItineraryItemCard.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/itinerary/ItineraryItemCard.kt
@@ -1,32 +1,54 @@
package com.appnotresponding.rumbo.ui.components.molecules.itinerary
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
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.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.res.painterResource
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
import coil3.compose.SubcomposeAsyncImage
import com.appnotresponding.rumbo.R
import com.appnotresponding.rumbo.models.Place
-import com.appnotresponding.rumbo.models.samplePlace
+import com.appnotresponding.rumbo.navigation.AppScreens
import com.appnotresponding.rumbo.ui.components.atoms.RumboButton
import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle
-import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel
+import java.text.Normalizer
+import java.time.DayOfWeek
+import java.time.LocalDateTime
+import java.time.LocalTime
+import java.time.format.DateTimeFormatter
+import java.util.Locale
/**
* Componente que muestra la información de un lugar en el itinerario, incluyendo su imagen, nombre, horario, precio y un botón para iniciar el desplazamiento.
@@ -34,75 +56,301 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme
* @param p El objeto Place que contiene la información del lugar a mostrar.
*/
@Composable
-fun ItineraryItemCard(p: Place) {
+fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel, controller: NavHostController) {
+ val placesState by placesViewModel.uiState.collectAsState()
+ val selectedPlace = placesState.selectedPlace
+ val isActiveRoute = selectedPlace?.id == p.id
- Row(modifier = Modifier.fillMaxWidth()) {
- Box(
+ var showReplaceRouteDialog by remember { mutableStateOf(false) }
+
+ if (showReplaceRouteDialog) {
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = {
+ showReplaceRouteDialog = false
+ },
+ title = { Text("Ruta activa") },
+ text = { Text("Tienes una ruta activa en curso. ¿Deseas reemplazarla?") },
+ confirmButton = {
+ androidx.compose.material3.TextButton(
+ onClick = {
+ showReplaceRouteDialog = false
+ placesViewModel.selectForNavigation(p)
+ controller.navigate(AppScreens.Map.name)
+ }) {
+ Text("Confirmar")
+ }
+ },
+ dismissButton = {
+ androidx.compose.material3.TextButton(
+ onClick = { showReplaceRouteDialog = false }) {
+ Text("Cancelar")
+ }
+ })
+ }
+
+ ElevatedCard(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(
+ containerColor = if (isActiveRoute) {
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
+ }
+ ),
+ elevation = CardDefaults.elevatedCardElevation(
+ defaultElevation = if (isActiveRoute) 4.dp else 2.dp
+ ),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Column (
modifier = Modifier
- .weight(4f)
- .aspectRatio(1f)
- .padding(start = 8.dp, end = 8.dp, bottom = 8.dp, top = 2.dp)
- .clip(MaterialTheme.shapes.medium)
- .background(MaterialTheme.colorScheme.secondaryContainer),
- contentAlignment = Alignment.Center
+ .padding(12.dp),
) {
- SubcomposeAsyncImage(
- model = p.imageUrl,
- contentDescription = "Imagen de ${p.name}",
- contentScale = ContentScale.Crop,
- error = {
- Image(
- painter = painterResource(R.drawable.ic_picture),
- contentDescription = null,
- colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondaryContainer)
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(4f)
+ .aspectRatio(1f)
+ .clip(MaterialTheme.shapes.medium)
+ .background(MaterialTheme.colorScheme.secondaryContainer),
+ contentAlignment = Alignment.Center
+ ) {
+ SubcomposeAsyncImage(
+ model = p.imageUrl,
+ contentDescription = "Imagen de ${p.name}",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ error = {
+ Image(
+ painter = painterResource(R.drawable.ic_picture),
+ contentDescription = null,
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondaryContainer),
+ contentScale = ContentScale.Crop
+ )
+ })
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Column(
+ modifier = Modifier
+ .weight(6f),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = p.name,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.weight(1f, fill = false)
+ )
+ if (isActiveRoute) {
+ Box(
+ modifier = Modifier
+ .background(
+ color = MaterialTheme.colorScheme.primary,
+ shape = MaterialTheme.shapes.small
+ )
+ .padding(horizontal = 8.dp, vertical = 2.dp)
+ ) {
+ Text(
+ text = "Activa",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ }
+ Text(
+ text = formatOpenHours(p.openHours),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RumboButton(
+ modifier = Modifier.weight(1f),
+ text = if (isActiveRoute) "Ver Ruta Activa" else "Iniciar Desplazamiento",
+ onClick = {
+ if (isActiveRoute) {
+ controller.navigate(AppScreens.Map.name)
+ } else if (selectedPlace != null) {
+ showReplaceRouteDialog = true
+ } else {
+ placesViewModel.selectForNavigation(p)
+ controller.navigate(AppScreens.Map.name)
+ }
+ },
+ style = if (isActiveRoute) RumboButtonStyle.Primary else RumboButtonStyle.Secondary,
+ icon = painterResource(R.drawable.ic_map)
+ )
+ IconButton(onClick = { placesViewModel.removeFromItinerary(p) }) {
+ Icon(
+ painter = painterResource(R.drawable.ic_destroy),
+ contentDescription = "Eliminar del Itinerario",
+ tint = MaterialTheme.colorScheme.error
)
- })
+ }
+ }
}
- Column(
- modifier = Modifier
- .weight(6f)
- .padding(top = 2.dp, end = 8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- text = p.name,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onBackground
- )
- Text(
- text = p.openHours,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onBackground
- )
- Text(
- text = p.price,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onBackground
- )
- RumboButton(
- text = "Iniciar Desplazamiento",
- onClick = { /* TODO */ },
- style = RumboButtonStyle.Secondary,
- icon = painterResource(R.drawable.ic_map)
- )
+ }
+}
+
+/**
+ * Construye un texto de estado de apertura para el dia y hora actuales.
+ *
+ * Reglas:
+ * - Sin datos o sin rangos validos: devuelve un mensaje generico.
+ * - Si ahora esta dentro de un rango: "Abierto hoy hasta ...".
+ * - Si hay un rango mas tarde hoy: "Cerrado ahora. Abre hoy a ...".
+ * - Si no hay mas rangos hoy: busca el proximo dia con apertura.
+ */
+fun formatOpenHours(
+ openHours: List?, now: LocalDateTime = LocalDateTime.now()
+): String {
+ if (openHours.isNullOrEmpty()) {
+ return "No hay información de horario"
+ }
+
+ val parsedHours = openHours.mapNotNull { parseWeekdayDescription(it) }
+ if (parsedHours.isEmpty()) {
+ return "No hay información de horario"
+ }
+
+ val normalizedHours = parsedHours.associateBy { normalizeDayKey(it.first) }
+ val todayKeySpanish = normalizeDayKey(dayOfWeekToSpanish(now.dayOfWeek))
+ val todayRanges =
+ (normalizedHours[todayKeySpanish])?.second.orEmpty().mapNotNull(::parseTimeRange)
+ .sortedBy { it.first }
+
+ val timeNow = now.toLocalTime()
+ val openRange = todayRanges.firstOrNull { timeNow >= it.first && timeNow < it.second }
+ if (openRange != null) {
+ return "Abierto hoy hasta ${openRange.second.format(TIME_FORMATTER)}"
+ }
+
+ val nextToday = todayRanges.firstOrNull { timeNow < it.first }
+ if (nextToday != null) {
+ return "Cerrado ahora. Abre hoy a ${nextToday.first.format(TIME_FORMATTER)}"
+ }
+
+ val nextOpening = findNextOpening(normalizedHours, now.dayOfWeek)
+ return if (nextOpening != null) {
+ val (dayLabel, startTime) = nextOpening
+ "Cerrado. Próxima apertura $dayLabel ${startTime.format(TIME_FORMATTER)}"
+ } else {
+ "Cerrado"
+ }
+}
+
+/** Formato de horas esperado en los rangos (p.ej. "9:00", "17:30"). */
+private val TIME_FORMATTER = DateTimeFormatter.ofPattern("H:mm")
+
+/**
+ * Parsea una linea tipo "lunes: 9:00-14:00, 16:00-20:00".
+ * Retorna el dia y los rangos como texto; si dice cerrado, retorna lista vacia.
+ */
+private fun parseWeekdayDescription(raw: String): Pair>? {
+ val parts = raw.split(":", limit = 2)
+ if (parts.size != 2) return null
+ val day = parts[0].trim()
+ val hoursText = parts[1].trim()
+ if (hoursText.isEmpty()) return day to emptyList()
+ val lowered = hoursText.lowercase(Locale.getDefault())
+ if (lowered.contains("cerrado") || lowered.contains("closed")) {
+ return day to emptyList()
+ }
+ // Normaliza guion largo a guion simple antes de separar rangos.
+ val cleaned = hoursText.replace("–", "-")
+ val ranges = cleaned.split(",").map { it.trim() }.filter { it.isNotEmpty() }
+ return day to ranges
+}
+
+/**
+ * Parsea un rango de horas "HH:mm-HH:mm" y valida que el fin sea posterior.
+ */
+private fun parseTimeRange(raw: String): Pair? {
+ val parts = raw.split("-").map { it.trim() }
+ if (parts.size != 2) return null
+ val start = parseTime(parts[0]) ?: return null
+ val end = parseTime(parts[1]) ?: return null
+ return if (end <= start) null else start to end
+}
+
+/**
+ * Intenta parsear una hora con el formato configurado.
+ */
+private fun parseTime(raw: String): LocalTime? {
+ try {
+ return LocalTime.parse(raw, TIME_FORMATTER)
+ } catch (_: Exception) {
+ }
+ return null
+}
+
+/**
+ * Busca el proximo dia (hasta 7 dias) con un rango de apertura y devuelve su inicio.
+ */
+private fun findNextOpening(
+ normalizedHours: Map>>, today: DayOfWeek
+): Pair? {
+ for (offset in 1..7) {
+ val nextDay = today.plus(offset.toLong())
+ val dayLabelSpanish = dayOfWeekToSpanish(nextDay)
+ val ranges = (normalizedHours[normalizeDayKey(dayLabelSpanish)])?.second.orEmpty()
+ .mapNotNull(::parseTimeRange).sortedBy { it.first }
+ val firstRange = ranges.firstOrNull()
+ if (firstRange != null) {
+ return dayLabelSpanish.replaceFirstChar { it.titlecase(Locale.getDefault()) } to firstRange.first
}
}
+ return null
+}
+/** Convierte un DayOfWeek a su nombre en español, para hacer match con el array que devuelve la api. */
+private fun dayOfWeekToSpanish(day: DayOfWeek): String = when (day) {
+ DayOfWeek.MONDAY -> "lunes"
+ DayOfWeek.TUESDAY -> "martes"
+ DayOfWeek.WEDNESDAY -> "miércoles"
+ DayOfWeek.THURSDAY -> "jueves"
+ DayOfWeek.FRIDAY -> "viernes"
+ DayOfWeek.SATURDAY -> "sábado"
+ DayOfWeek.SUNDAY -> "domingo"
}
+/**
+ * Normaliza el nombre del dia: minusculas y sin acentos (para llaves consistentes).
+ */
+private fun normalizeDayKey(day: String): String {
+ val lowered = day.trim().lowercase(Locale.getDefault())
+ val normalized = Normalizer.normalize(lowered, Normalizer.Form.NFD)
+ return normalized.replace(Regex("\\p{Mn}+"), "")
+}
+/**
@Preview(showBackground = true, name = "ItineraryItemCard - Light")
@Composable
private fun ItineraryItemCardLightPreview() {
- RumboTheme(darkTheme = false) {
- ItineraryItemCard(p = samplePlace)
- }
+RumboTheme(darkTheme = false) {
+ItineraryItemCard(p = samplePlace)
+}
}
@Preview(showBackground = true, name = "ItineraryItemCard - Dark", backgroundColor = 0xFF1E1E1E)
@Composable
private fun ItineraryItemCardDarkPreview() {
- RumboTheme(darkTheme = true) {
- ItineraryItemCard(p = samplePlace)
- }
+RumboTheme(darkTheme = true) {
+ItineraryItemCard(p = samplePlace)
+}
}
+ */
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/DropNoteBubble.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/DropNoteBubble.kt
index 7536325..f3babc1 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/DropNoteBubble.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/DropNoteBubble.kt
@@ -4,15 +4,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.shadow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.appnotresponding.rumbo.models.DropNote
@@ -21,6 +18,8 @@ import com.appnotresponding.rumbo.models.sampleDropNote
import com.appnotresponding.rumbo.ui.components.atoms.Avatar
import com.appnotresponding.rumbo.ui.components.atoms.AvatarSize
import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import androidx.compose.foundation.layout.padding
+import com.appnotresponding.rumbo.models.sampleUser
/**
* Un composable que representa una DropNote como un globo de pensamiento con el avatar del autor y dos "puntos" de pensamiento.
@@ -35,51 +34,41 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme
@Composable
fun DropNoteBubble(
d: DropNote,
+ author: User?,
modifier: Modifier = Modifier,
) {
val borderColor = MaterialTheme.colorScheme.secondary
val bgColor = MaterialTheme.colorScheme.surfaceContainerHighest
- Box(modifier = modifier) {
- // Main bubble
- Box(
- modifier = Modifier
- .align(Alignment.TopCenter)
- .shadow(elevation = 6.dp, shape = CircleShape, clip = false)
- .background(color = bgColor, shape = CircleShape)
- .border(width = 3.dp, color = borderColor, shape = CircleShape)
- .padding(3.dp)
- .clip(CircleShape), contentAlignment = Alignment.Center
- ) {
- Avatar(
- user = User(
- id = d.user.id,
- name = d.user.name,
- email = d.user.email,
- phone = d.user.phone,
- profilePictureUrl = d.authorAvatarUrl
- ), size = AvatarSize.Large
- )
- }
+ Box(
+ modifier = modifier.size(48.dp)
+ ) {
+ Avatar(
+ user = author,
+ size = AvatarSize.Medium,
+ borderWidth = 2.dp,
+ borderColor = borderColor,
+ modifier = Modifier.align(Alignment.TopCenter)
+ )
// Medium thought dot
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
- .offset(x = 18.dp, y = (-3).dp)
- .size(12.dp)
+ .offset(x = 10.dp, y = (-2).dp)
+ .size(7.dp)
.background(color = bgColor, shape = CircleShape)
- .border(width = 2.dp, color = borderColor, shape = CircleShape)
+ .border(width = 1.5.dp, color = borderColor, shape = CircleShape)
)
// Small thought dot
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
- .offset(x = 24.dp, y = 0.dp)
- .size(7.dp)
+ .offset(x = 14.dp, y = 1.dp)
+ .size(4.dp)
.background(color = bgColor, shape = CircleShape)
- .border(width = 1.5.dp, color = borderColor, shape = CircleShape)
+ .border(width = 1.0.dp, color = borderColor, shape = CircleShape)
)
}
}
@@ -90,7 +79,8 @@ private fun DropNoteBubbleLightPreview() {
RumboTheme(darkTheme = false) {
Box(modifier = Modifier.padding(16.dp)) {
DropNoteBubble(
- d = sampleDropNote
+ d = sampleDropNote,
+ author = sampleUser
)
}
}
@@ -102,9 +92,9 @@ private fun DropNoteBubbleDarkPreview() {
RumboTheme(darkTheme = true) {
Box(modifier = Modifier.padding(16.dp)) {
DropNoteBubble(
- d = sampleDropNote
+ d = sampleDropNote,
+ author = sampleUser
)
}
}
-}
-
+}
\ No newline at end of file
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 c9d511e..84a7e21 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
@@ -1,17 +1,28 @@
package com.appnotresponding.rumbo.ui.components.molecules.map
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
+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
@@ -37,6 +48,7 @@ fun WriteDropNote(onClick: () -> Unit = {}) {
) {
IconButton(onClick = onClick) {
Icon(
+ modifier = Modifier.size(24.dp),
painter = painterResource(R.drawable.ic_pencil),
contentDescription = "Write Drop Note",
tint = MaterialTheme.colorScheme.onPrimary,
@@ -61,6 +73,7 @@ fun LocateMe(onClick: () -> Unit = {}) {
) {
IconButton(onClick = onClick) {
Icon(
+ modifier = Modifier.size(24.dp),
painter = painterResource(R.drawable.ic_location_crosshairs),
contentDescription = "Locate Me",
tint = MaterialTheme.colorScheme.onPrimary,
@@ -69,6 +82,120 @@ fun LocateMe(onClick: () -> Unit = {}) {
}
}
+@Composable
+fun CancelRoute(onClick: () -> Unit = {}) {
+ Box(
+ modifier = Modifier
+ .aspectRatio(1f)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center
+ ) {
+ IconButton(onClick = onClick) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(R.drawable.ic_cancel),
+ contentDescription = "Cancel Route",
+ tint = MaterialTheme.colorScheme.onPrimary,
+ )
+ }
+ }
+}
+
+@Composable
+fun ToggleHeatmap(isHeatmapActive: Boolean = false, onClick: () -> Unit = {}) {
+ val bgColor = if (isHeatmapActive) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary
+ val iconTint = if (isHeatmapActive) MaterialTheme.colorScheme.onTertiary else MaterialTheme.colorScheme.onPrimary
+ Box(
+ modifier = Modifier
+ .aspectRatio(1f)
+ .clip(CircleShape)
+ .background(bgColor), contentAlignment = Alignment.Center
+ ) {
+ IconButton(onClick = onClick) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(R.drawable.ic_heat),
+ contentDescription = "Toggle Heatmap",
+ tint = iconTint,
+ )
+ }
+ }
+}
+
+@Composable
+fun ToggleUserRoute(isUserRouteActive: Boolean = false, onClick: () -> Unit = {}) {
+ val bgColor = if (isUserRouteActive) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary
+ val iconTint = if (isUserRouteActive) MaterialTheme.colorScheme.onTertiary else MaterialTheme.colorScheme.onPrimary
+ val iconRes = if (isUserRouteActive) R.drawable.ic_eye_open else R.drawable.ic_eye_crossed
+ Box(
+ modifier = Modifier
+ .aspectRatio(1f)
+ .clip(CircleShape)
+ .background(bgColor), contentAlignment = Alignment.Center
+ ) {
+ IconButton(onClick = onClick) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(iconRes),
+ contentDescription = "Toggle User Route",
+ tint = iconTint,
+ )
+ }
+ }
+}
+
+@Composable
+fun ExpandableFAB(
+ isHeatmapActive: Boolean,
+ onHeatmapClick: () -> Unit,
+ onDropNoteClick: () -> Unit,
+ onLocateMeClick: () -> Unit,
+ isUserRouteActive: Boolean,
+ onUserRouteClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ var isExpanded by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ AnimatedVisibility(
+ visible = isExpanded,
+ enter = fadeIn() + slideInVertically(initialOffsetY = { it / 2 }),
+ exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 2 })
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ ToggleHeatmap(isHeatmapActive = isHeatmapActive, onClick = onHeatmapClick)
+ WriteDropNote(onClick = onDropNoteClick)
+ LocateMe(onClick = onLocateMeClick)
+ ToggleUserRoute(isUserRouteActive = isUserRouteActive, onClick = onUserRouteClick)
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .aspectRatio(1f)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center
+ ) {
+ IconButton(onClick = { isExpanded = !isExpanded }) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(if (isExpanded) R.drawable.ic_minus else R.drawable.ic_plus),
+ contentDescription = "Expand / Collapse Menu",
+ tint = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ }
+}
+
@Preview(showBackground = true, name = "MapFloatingActions - Light")
@Composable
private fun MapFloatingActionsLightPreview() {
@@ -82,6 +209,9 @@ private fun MapFloatingActionsLightPreview() {
Box(modifier = Modifier.size(56.dp)) {
LocateMe()
}
+ Box(modifier = Modifier.size(56.dp)) {
+ CancelRoute()
+ }
}
}
}
@@ -99,6 +229,9 @@ private fun MapFloatingActionsDarkPreview() {
Box(modifier = Modifier.size(56.dp)) {
LocateMe()
}
+ Box(modifier = Modifier.size(56.dp)) {
+ CancelRoute()
+ }
}
}
}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/PlaceReview.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/PlaceReview.kt
index 01ba420..02103e8 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/PlaceReview.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/PlaceReview.kt
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
@@ -37,7 +38,14 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme
* @param p El objeto Place que contiene la información del lugar a mostrar
*/
@Composable
-fun PlaceInfo(p: Place) {
+fun PlaceInfo(
+ p: Place,
+ onNavigateClick: () -> Unit = {},
+ onReviewClick: () -> Unit = {},
+ onAddToItineraryClick: () -> Unit = {},
+ isInItinerary: Boolean = false,
+ isReviewEnabled: Boolean = true
+) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -56,11 +64,13 @@ fun PlaceInfo(p: Place) {
model = p.imageUrl,
contentDescription = "Imagen de ${p.name}",
contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
error = {
Image(
painter = painterResource(R.drawable.ic_picture),
contentDescription = null,
- colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondaryContainer)
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondaryContainer),
+ contentScale = ContentScale.Crop
)
})
}
@@ -75,17 +85,59 @@ fun PlaceInfo(p: Place) {
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground
)
+
+ p.openHours?.let { hours ->
+ if (hours.isNotEmpty() && hours.first().isNotBlank()) {
+ Text(
+ text = "Horario: ${hours.joinToString(", ")}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+ }
+
+ p.price?.let { price ->
+ if (price.isNotBlank()) {
+ Text(
+ text = "Precio: $price",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+ }
+
Text(
- text = p.description,
+ text = p.description ?: "No hay información",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground
)
- RumboButton(
- text = "Escribir Reseña",
- onClick = {},
- style = RumboButtonStyle.Secondary,
- icon = painterResource(R.drawable.ic_text_box_edit)
- )
+
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ RumboButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = "Navegar al lugar",
+ onClick = onNavigateClick,
+ style = RumboButtonStyle.Primary,
+ icon = painterResource(R.drawable.ic_map)
+ )
+ val itineraryText = if (isInItinerary) "Eliminar del Itinerario" else "Añadir al Itinerario"
+ val itineraryIcon = if (isInItinerary) R.drawable.ic_minus else R.drawable.ic_plus
+ RumboButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = itineraryText,
+ onClick = onAddToItineraryClick,
+ style = RumboButtonStyle.Secondary,
+ icon = painterResource(itineraryIcon)
+ )
+ RumboButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = "Escribir Reseña",
+ onClick = onReviewClick,
+ style = RumboButtonStyle.Secondary,
+ icon = painterResource(R.drawable.ic_text_box_edit),
+ enabled = isReviewEnabled
+ )
+ }
}
}
}
@@ -119,10 +171,22 @@ fun ReviewItem(r: Review) {
)
RumboRatingDisplay(rating = r.rating)
Text(
- text = "¡Me encantó este lugar! La vista es increíble y el ambiente es muy relajante. Definitivamente volveré.",
+ text = r.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground
)
+ if (!r.photoUrl.isNullOrEmpty()) {
+ SubcomposeAsyncImage(
+ model = r.photoUrl,
+ contentDescription = "Foto adjunta",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ .aspectRatio(16f/9f)
+ .clip(MaterialTheme.shapes.medium)
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/plan/PlanItemCard.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/plan/PlanItemCard.kt
index 10e9e75..083819f 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/plan/PlanItemCard.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/plan/PlanItemCard.kt
@@ -2,31 +2,40 @@ package com.appnotresponding.rumbo.ui.components.molecules.plan
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
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.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.res.painterResource
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
import coil3.compose.SubcomposeAsyncImage
import com.appnotresponding.rumbo.R
import com.appnotresponding.rumbo.models.Place
-import com.appnotresponding.rumbo.models.samplePlace
+import com.appnotresponding.rumbo.navigation.AppScreens
import com.appnotresponding.rumbo.ui.components.atoms.RumboButton
import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle
-import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import com.appnotresponding.rumbo.ui.components.atoms.RumboRatingDisplay
+import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel
/**
* PlanItemCard.kt
@@ -37,78 +46,105 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme
* @param p El lugar a mostrar en la tarjeta.
*/
@Composable
-fun PlanItemCard(p: Place) {
+fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel, controller: NavHostController) {
- var icon = R.drawable.ic_plus
- var msg = "Añadir al Itinerario"
+ val uiState by placesViewModel.uiState.collectAsState()
+ val isInItinerary = uiState.itinerary.any { it.id == p.id }
+ val icon = if (isInItinerary) R.drawable.ic_minus else R.drawable.ic_plus
+ val msg = if (isInItinerary) "Eliminar del Itinerario" else "Añadir al Itinerario"
- Row(modifier = Modifier.fillMaxWidth()) {
- Box(
- modifier = Modifier
- .weight(4f)
- .aspectRatio(1f)
- .padding(start = 8.dp, end = 8.dp, bottom = 8.dp, top = 2.dp)
- .clip(MaterialTheme.shapes.medium)
- .background(MaterialTheme.colorScheme.secondaryContainer),
- contentAlignment = Alignment.Center
- ) {
- SubcomposeAsyncImage(
- model = p.imageUrl,
- contentDescription = "Imagen de ${p.name}",
- contentScale = ContentScale.Crop,
- error = {
- Image(
- painter = painterResource(R.drawable.ic_picture),
- contentDescription = null,
- colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondaryContainer)
+ ElevatedCard(
+ modifier = Modifier.fillMaxWidth(), colors = CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
+ ), elevation = CardDefaults.elevatedCardElevation(
+ defaultElevation = 2.dp
+ ), shape = MaterialTheme.shapes.medium
+ ) {
+ Column {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(4f)
+ .aspectRatio(1f)
+ .clip(MaterialTheme.shapes.medium)
+ .background(MaterialTheme.colorScheme.secondaryContainer)
+ .clickable {
+ placesViewModel.showPreview(p)
+ placesViewModel.selectForNavigation(p)
+ controller.navigate(AppScreens.Map.name)
+ }, contentAlignment = Alignment.Center
+ ) {
+ SubcomposeAsyncImage(
+ model = p.imageUrl,
+ contentDescription = "Imagen de ${p.name}",
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ error = {
+ Image(
+ painter = painterResource(R.drawable.ic_picture),
+ contentDescription = null,
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondaryContainer),
+ contentScale = ContentScale.Crop
+ )
+ })
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Column(
+ modifier = Modifier.weight(6f), verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = p.name,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ if (p.reviews.isNotEmpty()) {
+ RumboRatingDisplay(
+ rating = p.reviews.map { it.rating }.average().toFloat(),
+ starSize = 14.dp
+ )
+ }
+ Text(
+ text = p.description ?: "No hay descripción",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onBackground
)
- })
+
+ }
+ }
}
- Column(
+ RumboButton(
modifier = Modifier
- .weight(6f)
- .padding(top = 2.dp, end = 8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- text = p.name,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onBackground
- )
- Text(
- text = p.description,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onBackground
- )
- Text(
- text = p.price,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onBackground
- )
- RumboButton(
- text = msg, onClick = {
- //Change icon to check
- icon = R.drawable.ic_check
- msg = "Añadido al Itinerario"
- }, style = RumboButtonStyle.Secondary, icon = painterResource(icon)
- )
- }
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ text = msg, onClick = {
+ if (isInItinerary) {
+ placesViewModel.removeFromItinerary(p)
+ } else {
+ placesViewModel.addToItinerary(p)
+ }
+ }, style = RumboButtonStyle.Secondary, icon = painterResource(icon)
+ )
}
-
}
+/**
@Preview(showBackground = true, name = "PlanItemCard - Light")
@Composable
private fun PlanItemCardLightPreview() {
- RumboTheme(darkTheme = false) {
- PlanItemCard(p = samplePlace)
- }
+RumboTheme(darkTheme = false) {
+PlanItemCard(p = samplePlace)
+}
}
@Preview(showBackground = true, name = "PlanItemCard - Dark", backgroundColor = 0xFF1E1E1E)
@Composable
private fun PlanItemCardDarkPreview() {
- RumboTheme(darkTheme = true) {
- PlanItemCard(p = samplePlace)
- }
-}
\ No newline at end of file
+RumboTheme(darkTheme = true) {
+PlanItemCard(p = samplePlace)
+}
+}
+ */
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/auth/LoginForm.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/auth/LoginForm.kt
index 5535825..5a39c18 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/auth/LoginForm.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/auth/LoginForm.kt
@@ -9,15 +9,14 @@ 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.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-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.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -27,57 +26,90 @@ import com.appnotresponding.rumbo.ui.components.molecules.auth.AuthEmailText
import com.appnotresponding.rumbo.ui.components.molecules.auth.AuthPasswordText
import com.appnotresponding.rumbo.ui.theme.RumboTheme
-
@Composable
fun LoginForm(
modifier: Modifier = Modifier,
- onLoginClick: () -> Unit = {},
+ //https://kotlinlang.org/docs/lambdas.html#higher-order-functions
+ email: String = "",
+ password: String = "",
+ onEmailChange: (String) -> Unit = {},
+ onPasswordChange: (String) -> Unit = {},
+ isLoading: Boolean = false,
+ errorMessage: String? = null,
+ hasBiometricCredentials: Boolean = false,
onForgotPasswordClick: () -> Unit = {},
+ onLoginClick: () -> Unit = {},
+ onBiometricClick: () -> Unit = {},
) {
- var email by remember { mutableStateOf("") }
- var password by remember { mutableStateOf("") }
+ val emailRegex = Regex("""^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$""")
+ val passwordRegex =
+ Regex("""^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$""")
+ val isLoginEnabled = emailRegex.matches(email) && passwordRegex.matches(password) && !isLoading
Column(
modifier = modifier
.fillMaxWidth()
-
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f))
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
-
AuthEmailText(
value = email,
- onValueChange = { email = it },
+ onValueChange = onEmailChange,
label = "Correo",
placeholder = "correo@gmail.com"
)
Spacer(modifier = Modifier.height(4.dp))
-
AuthPasswordText(
value = password,
- onValueChange = { password = it },
+ onValueChange = onPasswordChange,
label = "Contraseña",
placeholder = "********"
)
Spacer(modifier = Modifier.height(8.dp))
+ if (errorMessage != null) {
+ Text(
+ text = errorMessage, style = MaterialTheme.typography.bodySmall.copy(
+ color = MaterialTheme.colorScheme.error
+ ), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()
+ )
+ }
- RumboButton(
- text = "Iniciar Sesión",
- onClick = { onLoginClick() },
- style = RumboButtonStyle.Primary,
- modifier = Modifier.fillMaxWidth(),
- )
+ // Loading o botones
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ )
+ } else {
+ RumboButton(
+ text = "Iniciar Sesión",
+ onClick = onLoginClick,
+ style = RumboButtonStyle.Primary,
+ enabled = isLoginEnabled,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ // Solo aparece si ya hizo login antes en este dispositivo
+ if (hasBiometricCredentials) {
+ RumboButton(
+ text = "Iniciar con Biometricos",
+ onClick = onBiometricClick,
+ style = RumboButtonStyle.Secondary,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
Text(
text = "Recuperar contraseña",
- style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
+ style = MaterialTheme.typography.bodyMedium.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ ),
textDecoration = TextDecoration.Underline,
modifier = Modifier
.clickable(onClick = onForgotPasswordClick)
@@ -89,9 +121,27 @@ fun LoginForm(
@Preview(showBackground = true, name = "Login Form - Dark", backgroundColor = 0xFF121212)
@Composable
private fun LoginFormPreviewDark() {
+ RumboTheme(darkTheme = true) {
+ LoginForm(modifier = Modifier.padding(16.dp))
+ }
+}
+
+@Preview(showBackground = true, name = "Login Form - Con biometría", backgroundColor = 0xFF121212)
+@Composable
+private fun LoginFormBiometricPreview() {
RumboTheme(darkTheme = true) {
LoginForm(
- modifier = Modifier.padding(16.dp)
+ modifier = Modifier.padding(16.dp), hasBiometricCredentials = true
)
}
}
+
+@Preview(showBackground = true, name = "Login Form - Error", backgroundColor = 0xFF121212)
+@Composable
+private fun LoginFormErrorPreview() {
+ RumboTheme(darkTheme = true) {
+ LoginForm(
+ modifier = Modifier.padding(16.dp), errorMessage = "Correo o contraseña incorrectos"
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/auth/SignUpForm.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/auth/SignUpForm.kt
index 06b7ff7..049fecd 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/auth/SignUpForm.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/auth/SignUpForm.kt
@@ -1,22 +1,29 @@
+
package com.appnotresponding.rumbo.ui.components.organisms.auth
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -27,12 +34,14 @@ 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.layout.ContentScale
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
import com.appnotresponding.rumbo.ui.components.atoms.RumboButton
import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle
import com.appnotresponding.rumbo.ui.components.atoms.RumboTextField
@@ -47,8 +56,11 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme
@Composable
fun SignUpForm(
modifier: Modifier = Modifier,
- onClick: ()->Unit = {},
+ //https://kotlinlang.org/docs/lambdas.html#higher-order-functions
+ onClick: (name: String, phone: String, email: String, password: String) -> Unit = { _, _, _, _ -> },
) {
+
+ var profilePictureUrl by remember { mutableStateOf("") }
var fullName by remember { mutableStateOf("") }
var phone by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
@@ -60,6 +72,14 @@ fun SignUpForm(
var termsAccepted by remember { mutableStateOf(false) }
+ val phoneRegex = Regex("^\\+\\d{10,14}$")
+ val emailRegex = Regex("""^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$""")
+ val passwordRegex =
+ Regex("""^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$""")
+ val isSignUpEnabled =
+ fullName.isNotBlank() && phoneRegex.matches(phone) && emailRegex.matches(email) && passwordRegex.matches(
+ password
+ ) && termsAccepted
Column(
modifier = modifier
@@ -67,8 +87,37 @@ fun SignUpForm(
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f))
.padding(24.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
) {
+
+ Box(
+ modifier = Modifier
+ .size(100.dp)
+ .aspectRatio(1f)
+ .fillMaxWidth()
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .clickable { /*TODO*/ }, contentAlignment = Alignment.Center
+ ) {
+ if (profilePictureUrl != "") {
+ AsyncImage(
+ model = profilePictureUrl,
+ contentDescription = "Foto de perfil",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(100.dp)
+ .clip(CircleShape)
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Outlined.AddAPhoto,
+ contentDescription = "Seleccionar foto",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+
// Campo: Nombre Completo
AuthPlainText(
value = fullName,
@@ -129,8 +178,7 @@ fun SignUpForm(
countries.forEach { selectionOption ->
DropdownMenuItem(text = {
Text(
- text = selectionOption,
- color = MaterialTheme.colorScheme.onSurface
+ text = selectionOption, color = MaterialTheme.colorScheme.onSurface
)
}, onClick = {
selectedCountry = selectionOption
@@ -182,8 +230,9 @@ fun SignUpForm(
// Botón: Registrarse
RumboButton(
text = "Registrarse",
- onClick = onClick,
+ onClick = { onClick(fullName, phone, email, password) },
style = RumboButtonStyle.Primary,
+ enabled = isSignUpEnabled,
modifier = Modifier.fillMaxWidth(),
)
}
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 5613d83..3c60950 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
@@ -6,15 +6,22 @@ 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.offset
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 +35,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 +55,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 +175,29 @@ 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.TopEnd)
+ .offset(x = (8).dp, y = (-4).dp)
+ .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
@@ -148,11 +223,11 @@ fun Nav(
) {
Icon(
painter = painterResource(R.drawable.ic_search),
- contentDescription = "Plan",
+ contentDescription = "Places",
tint = if (activeItem == NavItem.Plan) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
Text(
- text = "Plan",
+ text = "Sitios",
color = if (activeItem == NavItem.Plan) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
@@ -180,7 +255,7 @@ fun Nav(
tint = if (activeItem == NavItem.Itinerary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
Text(
- text = "Itinerario",
+ text = "Itin.",
color = if (activeItem == NavItem.Itinerary) 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 680b19c..a4388b1 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
@@ -1,6 +1,7 @@
package com.appnotresponding.rumbo.ui.components.organisms.common
import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -8,6 +9,10 @@ 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.filled.ExitToApp
+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
@@ -15,11 +20,14 @@ 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
import com.appnotresponding.rumbo.R
import com.appnotresponding.rumbo.models.User
import com.appnotresponding.rumbo.models.sampleUser
+import com.appnotresponding.rumbo.navigation.AppScreens
import com.appnotresponding.rumbo.ui.components.atoms.Avatar
import com.appnotresponding.rumbo.ui.theme.RumboTheme
@@ -31,16 +39,16 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme
* y la foto de perfil para el avatar.
*/
@Composable
-fun MainTopBar(u: User) {
+fun MainTopBar(u: User, controller: NavHostController) {
val bottomRoundedShape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp)
+ val displayName = u.name.replace(Regex(" +$"), "")
Surface(
shape = bottomRoundedShape, color = MaterialTheme.colorScheme.surfaceContainerLow,
) {
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
) {
@@ -55,11 +63,13 @@ fun MainTopBar(u: User) {
verticalAlignment = Alignment.CenterVertically
) {
Text(
- text = "¡Hola, ${u.name}!",
+ text = "¡Hola, ${displayName}!",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface
)
- Avatar(user = u)
+ Avatar(modifier = Modifier.clickable(onClick = {
+ controller.navigate(AppScreens.Profile.name)
+ }), user = u)
}
}
}
@@ -76,45 +86,86 @@ fun MainTopBar(u: User) {
* (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(" +$"), "")
+
+ val subtitle: String? = when {
+ !activity.isNullOrBlank() -> activity
+ !isGroup && isOnline -> "En línea"
+ else -> null
+ }
+ val subtitleColor = if (!isGroup && isOnline)
+ androidx.compose.ui.graphics.Color(0xFF4CAF50)
+ else
+ MaterialTheme.colorScheme.primary
+
Surface(
shape = bottomRoundedShape, color = MaterialTheme.colorScheme.surfaceContainerLow,
) {
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 = u.name,
- style = MaterialTheme.typography.labelLarge,
- modifier = Modifier.padding(start = 8.dp),
- color = MaterialTheme.colorScheme.onSurface
- )
- if (activity != null) {
+ Row(
+ modifier = Modifier.weight(1f),
+ 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(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 8.dp)
+ ) {
Text(
- text = activity,
- style = MaterialTheme.typography.labelMedium,
- modifier = Modifier.padding(start = 8.dp),
- color = MaterialTheme.colorScheme.primary
+ text = displayName,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
)
+ if (subtitle != null) {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.labelMedium,
+ color = subtitleColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
}
}
}
}
}
+/*
@Preview(showBackground = true, name = "MainTopBar - Light")
@Composable
private fun MainTopBarLightPreview() {
RumboTheme(darkTheme = false) {
- MainTopBar(u = sampleUser)
+ MainTopBar(u = sampleUser,)
}
}
@@ -122,7 +173,7 @@ private fun MainTopBarLightPreview() {
@Composable
private fun MainTopBarDarkPreview() {
RumboTheme(darkTheme = true) {
- MainTopBar(u = sampleUser)
+ MainTopBar(u = sampleUser,)
}
}
@@ -140,5 +191,4 @@ private fun ChatTopBarDarkPreview() {
RumboTheme(darkTheme = true) {
ChatTopBar(u = sampleUser, activity = "Rumbo al Museo Nacional")
}
-}
-
+}*/
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/itinerary/ItineraryOverview.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/itinerary/ItineraryOverview.kt
index 092922b..cc4d5d4 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/itinerary/ItineraryOverview.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/itinerary/ItineraryOverview.kt
@@ -1,17 +1,18 @@
package com.appnotresponding.rumbo.ui.components.organisms.itinerary
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Spacer
+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.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
import com.appnotresponding.rumbo.models.Place
-import com.appnotresponding.rumbo.models.samplePlace
import com.appnotresponding.rumbo.ui.components.molecules.itinerary.ItineraryItemCard
-import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel
/**
*
@@ -23,33 +24,42 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme
*/
@Composable
-fun ItineraryOverview(itineraryList: List) {
+fun ItineraryOverview(
+ itineraryList: List,
+ placesViewModel: PlacesViewModel,
+ controller: NavHostController
+) {
LazyColumn(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(itineraryList) { place ->
- ItineraryItemCard(p = place)
+ ItineraryItemCard(p = place, placesViewModel, controller)
+ }
+ item {
+ Spacer(modifier = Modifier.height(96.dp))
}
}
}
+/**
@Preview(showBackground = true, name = "ItineraryOverview - Light")
@Composable
private fun ItineraryOverviewLightPreview() {
- RumboTheme(darkTheme = false) {
- ItineraryOverview(
- itineraryList = listOf(samplePlace, samplePlace, samplePlace)
- )
- }
+RumboTheme(darkTheme = false) {
+ItineraryOverview(
+itineraryList = listOf(samplePlace, samplePlace, samplePlace)
+)
+}
}
@Preview(showBackground = true, name = "ItineraryOverview - Dark", backgroundColor = 0xFF1E1E1E)
@Composable
private fun ItineraryOverviewDarkPreview() {
- RumboTheme(darkTheme = true) {
- ItineraryOverview(
- itineraryList = listOf(samplePlace, samplePlace, samplePlace)
- )
- }
-}
\ No newline at end of file
+RumboTheme(darkTheme = true) {
+ItineraryOverview(
+itineraryList = listOf(samplePlace, samplePlace, samplePlace)
+)
+}
+}
+ */
\ No newline at end of file
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 39e12b9..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
@@ -1,11 +1,13 @@
package com.appnotresponding.rumbo.ui.components.organisms.map
+import android.net.Uri
import androidx.compose.foundation.background
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@@ -19,9 +21,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+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 coil3.compose.AsyncImage
import com.appnotresponding.rumbo.R
import com.appnotresponding.rumbo.ui.theme.RumboTheme
@@ -31,7 +35,9 @@ fun DropNoteComposer(
value: String = "",
onValueChange: (String) -> Unit = {},
onSendClick: () -> Unit = {},
- onImageClick: () -> Unit = {}
+ onImageClick: () -> Unit = {},
+ onGalleryClick: () -> Unit = {},
+ imageUri: Uri? = null
) {
Surface(
modifier = modifier.fillMaxWidth(),
@@ -40,7 +46,8 @@ fun DropNoteComposer(
tonalElevation = 2.dp
) {
Column(
- modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)
+ modifier = Modifier.padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Text input area
BasicTextField(
@@ -63,7 +70,20 @@ fun DropNoteComposer(
}
innerTextField()
}
- })
+ }
+ )
+
+ if (imageUri != null) {
+ AsyncImage(
+ model = imageUri,
+ contentDescription = "Imagen adjunta",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(150.dp)
+ .clip(MaterialTheme.shapes.medium),
+ contentScale = ContentScale.Crop
+ )
+ }
// Bottom row: action icons on the left, send button on the right
Row(
@@ -76,10 +96,20 @@ fun DropNoteComposer(
horizontalArrangement = Arrangement.spacedBy(0.dp),
verticalAlignment = Alignment.CenterVertically
) {
+ // Botón cámara
IconButton(onClick = onImageClick, modifier = Modifier.size(40.dp)) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_camera),
+ contentDescription = "Cámara",
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ // Botón galería
+ IconButton(onClick = onGalleryClick, modifier = Modifier.size(40.dp)) {
Icon(
painter = painterResource(id = R.drawable.ic_add_image),
- contentDescription = "Adjuntar imagen",
+ contentDescription = "Galería",
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -106,17 +136,17 @@ fun DropNoteComposer(
}
}
-@Preview(showBackground = true, name = "PlacePreviewCard - Dark")
+@Preview(showBackground = true, name = "DropNoteComposer - Light")
@Composable
-private fun DropNoteComposerDarkPreview() {
- RumboTheme(darkTheme = true) {
+private fun DropNoteComposerLightPreview() {
+ RumboTheme(darkTheme = false) {
DropNoteComposer()
}
}
-@Preview(showBackground = true, backgroundColor = 0xFF1E1E1E, name = "PlacePreviewCard - Dark")
+@Preview(showBackground = true, backgroundColor = 0xFF1E1E1E, name = "DropNoteComposer - Dark")
@Composable
-private fun DropNoteComposerLightPreview() {
+private fun DropNoteComposerDarkPreview() {
RumboTheme(darkTheme = true) {
DropNoteComposer()
}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/POICardRview.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/POICardRview.kt
index 80dbfa2..0e94127 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/POICardRview.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/POICardRview.kt
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -19,9 +20,27 @@ import com.appnotresponding.rumbo.models.Review
import com.appnotresponding.rumbo.models.User
import com.appnotresponding.rumbo.models.samplePlace
import com.appnotresponding.rumbo.models.sampleReview
-import com.appnotresponding.rumbo.ui.components.molecules.map.PlaceInfo
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.draw.clip
+import androidx.compose.foundation.background
+import androidx.compose.foundation.Image
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import coil3.compose.SubcomposeAsyncImage
+import com.appnotresponding.rumbo.ui.components.atoms.RumboButton
+import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle
+import com.appnotresponding.rumbo.R
import com.appnotresponding.rumbo.ui.components.molecules.map.ReviewItem
import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import java.util.Locale
/**
* Componente que muestra una tarjeta de vista previa de un lugar, incluyendo su información y una lista de reseñas asociadas.
@@ -29,27 +48,140 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme
* @param reviews La lista de objetos Review que contienen las reseñas asociadas al lugar
*/
@Composable
-fun PlacePreviewCard(place: Place, reviews: List) {
+fun formatHoursForCard(openHours: List?): String {
+ if (openHours.isNullOrEmpty()) return "No disponible"
+ return openHours.joinToString("\n") { hours ->
+ hours.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
+ }
+}
+
+@Composable
+fun PlacePreviewCard(
+ place: Place,
+ reviews: List,
+ onNavigateClick: () -> Unit = {},
+ onReviewClick: () -> Unit = {},
+ onAddToItineraryClick: () -> Unit = {},
+ isInItinerary: Boolean = false,
+ isReviewEnabled: Boolean = true
+) {
Surface(shape = MaterialTheme.shapes.large) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
- PlaceInfo(p = place)
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(96.dp)
+ .clip(MaterialTheme.shapes.medium)
+ .background(MaterialTheme.colorScheme.secondaryContainer),
+ contentAlignment = Alignment.Center
+ ) {
+ SubcomposeAsyncImage(
+ model = place.imageUrl,
+ contentDescription = "Imagen de ${place.name}",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ error = {
+ Image(
+ painter = painterResource(R.drawable.ic_picture),
+ contentDescription = null,
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondaryContainer),
+ contentScale = ContentScale.Crop
+ )
+ })
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = place.name,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.weight(1f)
+ )
+ }
+
LazyColumn(
modifier = Modifier
.fillMaxWidth()
+ .heightIn(max = 350.dp)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
- contentPadding = PaddingValues(vertical = 8.dp)
+ contentPadding = PaddingValues(bottom = 16.dp)
) {
+
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ place.openHours?.let { hours ->
+ if (hours.isNotEmpty() && hours.first().isNotBlank()) {
+ Text(
+ text = "Horario:\n${formatHoursForCard(hours)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+ }
+ place.price?.let { price ->
+ if (price.isNotBlank()) {
+ Text(
+ text = "Precio: $price",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ }
+ }
+ Text(
+ text = place.description ?: "No hay información",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ RumboButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = "Navegar al lugar",
+ onClick = onNavigateClick,
+ style = RumboButtonStyle.Primary,
+ icon = painterResource(R.drawable.ic_map)
+ )
+ val itineraryText = if (isInItinerary) "Eliminar del Itinerario" else "Añadir al Itinerario"
+ val itineraryIcon = if (isInItinerary) R.drawable.ic_minus else R.drawable.ic_plus
+ RumboButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = itineraryText,
+ onClick = onAddToItineraryClick,
+ style = RumboButtonStyle.Secondary,
+ icon = painterResource(itineraryIcon)
+ )
+ RumboButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = "Escribir Reseña",
+ onClick = onReviewClick,
+ style = RumboButtonStyle.Secondary,
+ icon = painterResource(R.drawable.ic_text_box_edit),
+ enabled = isReviewEnabled
+ )
+ }
+ }
+ }
+
item {
Text(
text = "Reseñas de ${place.name}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
- modifier = Modifier.padding(bottom = 8.dp)
+ modifier = Modifier.padding(vertical = 8.dp)
)
}
items(items = reviews, key = { it.id }) { review ->
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/ReviewComposer.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/ReviewComposer.kt
new file mode 100644
index 0000000..ddc9e44
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/ReviewComposer.kt
@@ -0,0 +1,160 @@
+package com.appnotresponding.rumbo.ui.components.organisms.map
+
+import android.net.Uri
+import androidx.compose.foundation.background
+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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.BasicTextField
+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
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.appnotresponding.rumbo.R
+import com.appnotresponding.rumbo.ui.components.atoms.RumboRatingStar
+
+@Composable
+fun ReviewComposer(
+ modifier: Modifier = Modifier,
+ rating: Float = 0f,
+ onRatingChange: (Float) -> Unit = {},
+ textValue: String = "",
+ onTextChange: (String) -> Unit = {},
+ onSendClick: () -> Unit = {},
+ onImageClick: () -> Unit = {},
+ onGalleryClick: () -> Unit = {},
+ imageUri: Uri? = null,
+ isUploading: Boolean = false
+) {
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ tonalElevation = 2.dp
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ RumboRatingStar(
+ rating = rating,
+ starSize = 32.dp,
+ onRatingChanged = onRatingChange
+ )
+ }
+
+ // Text input area
+ BasicTextField(
+ value = textValue,
+ onValueChange = onTextChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp),
+ textStyle = MaterialTheme.typography.bodyLarge.copy(
+ color = MaterialTheme.colorScheme.onSurface
+ ),
+ decorationBox = { innerTextField ->
+ Box {
+ if (textValue.isEmpty()) {
+ Text(
+ text = "Cuéntanos sobre tu experiencia...",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ )
+ }
+ innerTextField()
+ }
+ }
+ )
+
+ if (imageUri != null) {
+ AsyncImage(
+ model = imageUri,
+ contentDescription = "Imagen adjunta",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(150.dp)
+ .clip(MaterialTheme.shapes.medium),
+ contentScale = ContentScale.Crop
+ )
+ }
+
+ // Bottom row: action icons on the left, send button on the right
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Action icons
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(0.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onImageClick, modifier = Modifier.size(40.dp)) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_picture),
+ contentDescription = "Cámara",
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ IconButton(onClick = onGalleryClick, modifier = Modifier.size(40.dp)) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_add_image),
+ contentDescription = "Galería",
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ // Send button
+ if (isUploading) {
+ androidx.compose.material3.CircularProgressIndicator(
+ modifier = Modifier.size(24.dp).padding(end = 8.dp),
+ color = MaterialTheme.colorScheme.primary
+ )
+ } else {
+ val canSend = rating > 0f && textValue.isNotBlank()
+ val buttonColor = if (canSend) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
+ val iconColor = if (canSend) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
+
+ IconButton(
+ onClick = { if (canSend) onSendClick() },
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(buttonColor)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_send),
+ contentDescription = "Enviar reseña",
+ modifier = Modifier.size(20.dp),
+ tint = iconColor
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/ViewDropnote.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/ViewDropnote.kt
index 73cc1bd..269d7f0 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/ViewDropnote.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/ViewDropnote.kt
@@ -3,7 +3,9 @@ package com.appnotresponding.rumbo.ui.components.organisms.map
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -11,21 +13,78 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
import com.appnotresponding.rumbo.models.User
import com.appnotresponding.rumbo.models.sampleUser
import com.appnotresponding.rumbo.ui.components.atoms.Avatar
import com.appnotresponding.rumbo.ui.components.atoms.AvatarSize
import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.TextButton
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import com.appnotresponding.rumbo.R
+
+fun formatTimestamp(timestamp: Long): String {
+ val date = Date(timestamp)
+ val now = Calendar.getInstance()
+ val timeCalendar = Calendar.getInstance().apply { timeInMillis = timestamp }
+
+ val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
+ val dateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault())
+
+ return when {
+ now.get(Calendar.YEAR) == timeCalendar.get(Calendar.YEAR) &&
+ now.get(Calendar.DAY_OF_YEAR) == timeCalendar.get(Calendar.DAY_OF_YEAR) -> {
+ "Hoy ${timeFormat.format(date)}"
+ }
+ now.get(Calendar.YEAR) == timeCalendar.get(Calendar.YEAR) &&
+ now.get(Calendar.DAY_OF_YEAR) - timeCalendar.get(Calendar.DAY_OF_YEAR) == 1 -> {
+ "Ayer ${timeFormat.format(date)}"
+ }
+ else -> {
+ dateFormat.format(date)
+ }
+ }
+}
+
@Composable
fun ViewDropNote(
modifier: Modifier = Modifier,
user: User,
content: String = "",
+ imageUrl: String? = null,
isPrivate: Boolean = false,
+ timestamp: Long = 0L,
+ showDeleteOption: Boolean = false,
+ onDeleteClick: () -> Unit = {}
) {
Surface(
modifier = modifier.fillMaxWidth(),
@@ -33,59 +92,151 @@ fun ViewDropNote(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 2.dp
) {
- Row(
+ Column(
modifier = Modifier
.fillMaxWidth()
- .padding(12.dp),
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- verticalAlignment = Alignment.Top
+ .padding(10.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
) {
- Avatar(
- user = user, size = AvatarSize.Medium
- )
- Column(
- verticalArrangement = Arrangement.spacedBy(4.dp)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
) {
Row(
- horizontalArrangement = Arrangement.spacedBy(6.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
- Text(
- text = user.name,
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface
- )
+ Avatar(user = user, size = AvatarSize.Medium)
+
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = user.name,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ if (timestamp > 0L) {
+ Text(
+ text = formatTimestamp(timestamp),
+ style = MaterialTheme.typography.labelSmall,
+ fontSize = 10.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
+ )
+ }
+ }
}
- Text(
- text = content,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ // Botón delete solo si aplica
+ if (showDeleteOption) {
+ var showConfirmDialog by remember { mutableStateOf(false) }
+
+ IconButton(
+ onClick = { showConfirmDialog = true },
+ modifier = Modifier.size(36.dp)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_destroy),
+ contentDescription = "Eliminar DropNote",
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+
+ if (showConfirmDialog) {
+ AlertDialog(
+ onDismissRequest = { showConfirmDialog = false },
+ title = {
+ Text(
+ text = "¿Eliminar DropNote?",
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ text = {
+ Text(
+ text = "¿Estás seguro de que quieres eliminar esta nota? Esta acción no se puede deshacer.",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ showConfirmDialog = false
+ onDeleteClick()
+ }) {
+ Text(
+ text = "Eliminar",
+ color = MaterialTheme.colorScheme.error,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showConfirmDialog = false }) {
+ Text(
+ text = "Cancelar",
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ )
+ }
+ }
+ }
+
+ Text(
+ text = content,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ if (!imageUrl.isNullOrEmpty()) {
+ var isImageExpanded by remember { mutableStateOf(false) }
+
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = "Imagen de la DropNote",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .clip(MaterialTheme.shapes.medium)
+ .clickable { isImageExpanded = true },
+ contentScale = ContentScale.Crop
)
+
+ if (isImageExpanded) {
+ Dialog(onDismissRequest = { isImageExpanded = false }) {
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = "Imagen ampliada",
+ modifier = Modifier
+ .fillMaxWidth().clip(RoundedCornerShape(12.dp))
+ .clickable { isImageExpanded = false },
+ contentScale = ContentScale.Fit
+ )
+ }
+ }
}
}
}
}
-@Preview(showBackground = true, name = "PlacePreviewCard - Light")
+@Preview(
+ showBackground = true,
+ name = "DropNote Real"
+)
@Composable
-private fun ViewDropNoteLightPreview() {
- RumboTheme(darkTheme = false) {
- ViewDropNote(
- user = sampleUser,
- content = "Los mejores tacos del universo, realemente nadie los hace igual!!!"
- )
- }
-}
-
-@Preview(showBackground = true, backgroundColor = 0xFF1E1E1E, name = "PlacePreviewCard - Dark")
-@Composable
-private fun ViewDropNoteDarkPreview() {
- RumboTheme(darkTheme = true) {
- ViewDropNote(
- user = sampleUser,
- content = "Los mejores tacos del universo, realemente nadie los hace igual!!!"
- )
+private fun ViewDropNoteRealPreview() {
+ RumboTheme {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ ViewDropNote(
+ user = sampleUser,
+ content = "Esta es una DropNote real con imagen, fecha y acciones.",
+ imageUrl = " ",
+ timestamp = System.currentTimeMillis(),
+ showDeleteOption = true
+ )
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/plan/PlanPOIList.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/plan/PlanPOIList.kt
index 8b5bd5d..0f3f0d8 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/plan/PlanPOIList.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/plan/PlanPOIList.kt
@@ -1,6 +1,9 @@
package com.appnotresponding.rumbo.ui.components.organisms.plan
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Spacer
+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
@@ -12,6 +15,7 @@ import com.appnotresponding.rumbo.models.Place
import com.appnotresponding.rumbo.models.samplePlace
import com.appnotresponding.rumbo.ui.components.molecules.plan.PlanItemCard
import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel
/**
*
@@ -22,35 +26,42 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme
* @param places La lista de lugares (Place) que se van a mostrar en la pantalla.
*/
+import androidx.navigation.NavHostController
+
@Composable
-fun PlanPOIList(places: List) {
+fun PlanPOIList(places: List, placesViewModel: PlacesViewModel, controller: NavHostController) {
LazyColumn(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(places) { place ->
- PlanItemCard(p = place)
+ PlanItemCard(p = place, placesViewModel, controller)
+ }
+ item {
+ Spacer(modifier = Modifier.height(96.dp))
}
}
}
+/**
@Preview(showBackground = true, name = "PlanPOIList - Light")
@Composable
private fun PlanPOIListLightPreview() {
- RumboTheme(darkTheme = false) {
- PlanPOIList(
- places = listOf(samplePlace, samplePlace, samplePlace)
- )
- }
+RumboTheme(darkTheme = false) {
+PlanPOIList(
+places = listOf(samplePlace, samplePlace, samplePlace)
+)
+}
}
@Preview(showBackground = true, name = "PlanPOIList - Dark", backgroundColor = 0xFF1E1E1E)
@Composable
private fun PlanPOIListDarkPreview() {
- RumboTheme(darkTheme = true) {
- PlanPOIList(
- places = listOf(samplePlace, samplePlace, samplePlace)
- )
- }
-}
\ No newline at end of file
+RumboTheme(darkTheme = true) {
+PlanPOIList(
+places = listOf(samplePlace, samplePlace, samplePlace)
+)
+}
+}
+ */
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/LoginScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/LoginScreen.kt
index ae327a8..5dcd8c8 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/LoginScreen.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/LoginScreen.kt
@@ -1,33 +1,72 @@
package com.appnotresponding.rumbo.ui.screens.auth
-import androidx.compose.runtime.Composable
+import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
+import com.appnotresponding.rumbo.auth
+import com.appnotresponding.rumbo.models.AuthResult
+import com.appnotresponding.rumbo.models.LoginViewModel
import com.appnotresponding.rumbo.navigation.AppScreens
import com.appnotresponding.rumbo.ui.components.organisms.auth.LoginForm
import com.appnotresponding.rumbo.ui.templates.AuthTemplate
import com.appnotresponding.rumbo.ui.theme.RumboTheme
-
@Composable
-fun LoginScreen(
- controller: NavHostController
+fun LogInScreen(
+ controller: NavHostController, viewModel: LoginViewModel = viewModel()
) {
+ val context = LocalContext.current
+ val activity = context as FragmentActivity
+ val state by viewModel.loginState.collectAsState()
+
+ // Inicializar prefs con contexto (solo la primera vez)
+ LaunchedEffect(Unit) {
+ viewModel.initPrefs(context)
+
+ // Si ya hay sesión activa, saltar directo al mapa
+ auth.currentUser?.let {
+ controller.navigate(AppScreens.Map.name) {
+ popUpTo(AppScreens.Splash.name) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ }
+
+ // Navegar cuando el login sea exitoso
+ LaunchedEffect(state.authResult) {
+ if (state.authResult is AuthResult.Success) {
+ controller.navigate(AppScreens.Map.name) {
+ popUpTo(AppScreens.Splash.name) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ }
+
AuthTemplate {
LoginForm(
modifier = Modifier,
- onLoginClick = { controller.navigate(AppScreens.Map.name) },
+ email = state.email,
+ password = state.password,
+ onEmailChange = viewModel::updateEmail,
+ onPasswordChange = viewModel::updatePassword,
+ isLoading = state.authResult is AuthResult.Loading,
+ errorMessage = (state.authResult as? AuthResult.Error)?.message,
+ hasBiometricCredentials = state.hasBiometricCredentials,
+ onLoginClick = { viewModel.loginWithEmailPassword() },
+ onBiometricClick = { viewModel.loginWithBiometric(activity) },
)
}
}
-
-@Preview(showBackground = true, name = "Pantalla Login demostración ", backgroundColor = 0xFF121212)
+@Preview(showBackground = true, backgroundColor = 0xFF121212)
@Composable
private fun LoginScreenPreview() {
RumboTheme(darkTheme = true) {
- LoginScreen(controller = rememberNavController())
+ LogInScreen(rememberNavController())
}
-}
+}
\ No newline at end of file
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 714d3dd..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
@@ -1,43 +1,234 @@
package com.appnotresponding.rumbo.ui.screens.auth
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+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.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.layout.size
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.AddAPhoto
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+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.ui.platform.LocalContext
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+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
-import com.appnotresponding.rumbo.ui.components.organisms.auth.SignUpForm
+import com.appnotresponding.rumbo.ui.components.atoms.RumboButton
+import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle
+import com.appnotresponding.rumbo.ui.components.molecules.auth.AuthEmailText
+import com.appnotresponding.rumbo.ui.components.molecules.auth.AuthPasswordText
+import com.appnotresponding.rumbo.ui.components.molecules.auth.AuthPhoneText
+import com.appnotresponding.rumbo.ui.components.molecules.auth.AuthPlainText
import com.appnotresponding.rumbo.ui.templates.AuthTemplate
import com.appnotresponding.rumbo.ui.theme.RumboTheme
-
@Composable
fun SignUpScreen(
- controller: NavController
+ controller: NavController, registerViewModel: RegisterViewModel = viewModel()
) {
+ val state by registerViewModel.registerState.collectAsState()
+ val context = LocalContext.current
+ val imagePicker = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent()
+ ) { uri: Uri? -> registerViewModel.updatePhoto(uri) }
AuthTemplate {
-
- val scrollState = rememberScrollState()
-
SignUpForm(
- onClick = { controller.navigate(AppScreens.OnBoarding.name) },
- modifier = Modifier
- .verticalScroll(scrollState)
- .fillMaxSize()
+ state = state,
+ isFormValid = registerViewModel.isFormValid(),
+ onNameChange = { name ->
+ registerViewModel.updateName(name)
+ },
+ onLastNameChange = { lastName ->
+ registerViewModel.updateLastname(lastName)
+ },
+ onPhoneChange = { phone ->
+ registerViewModel.updatePhone(phone)
+ },
+ onEmailChange = { email ->
+ registerViewModel.updateEmail(email)
+ },
+ onPasswordChange = { password ->
+ registerViewModel.updatePassword(password)
+ },
+ onPickPhoto = {
+ imagePicker.launch("image/*")
+ },
+ onRegister = {
+ registerViewModel.register(context) {
+ controller.navigate(AppScreens.OnBoarding.name) {
+ popUpTo(AppScreens.SignUp.name) { inclusive = true }
+ }
+ }
+ },
+ modifier = Modifier.fillMaxSize()
)
}
}
+@Composable
+fun SignUpForm(
+ modifier: Modifier = Modifier,
+ state: RegisterState,
+ isFormValid: Boolean,
+ onNameChange: (String) -> Unit,
+ onLastNameChange: (String) -> Unit,
+ onPhoneChange: (String) -> Unit,
+ onEmailChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onPickPhoto: () -> Unit,
+ onRegister: () -> Unit,
+) {
+ Box(modifier = modifier.fillMaxWidth().padding(vertical = 32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .blur(50.dp)
+ .background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(50))
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(24.dp))
+ .background(
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.25f),
+ shape = RoundedCornerShape(24.dp)
+ )
+ .border(1.2.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(24.dp))
+ .padding(24.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(
+ modifier = Modifier
+ .size(100.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .clickable { onPickPhoto() }, contentAlignment = Alignment.Center
+ ) {
+ if (state.photoUri != null) {
+ AsyncImage(
+ model = state.photoUri,
+ contentDescription = "Foto de perfil",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(100.dp)
+ .clip(CircleShape)
+ )
+ } else {
+ Icon(
+ painter = painterResource(R.drawable.ic_add_image),
+ contentDescription = "Seleccionar foto",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+ Text(
+ text = "Toca para seleccionar foto",
+ style = MaterialTheme.typography.labelSmall.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ )
+ // Campo: Nombre
+ AuthPlainText(
+ value = state.name,
+ onValueChange = onNameChange,
+ label = "Nombres",
+ placeholder = "John"
+ )
+ // Campo: Apellidos
+ AuthPlainText(
+ value = state.lastname,
+ onValueChange = onLastNameChange,
+ label = "Apellidos",
+ placeholder = "Doe"
+ )
+ // Campo: Celular (Número de teléfono para persistir)
+ AuthPhoneText(
+ value = state.phone,
+ onValueChange = onPhoneChange,
+ label = "Celular",
+ placeholder = "+57 300 123 4567"
+ )
+ // Campo: Correo
+ AuthEmailText(
+ value = state.email,
+ onValueChange = onEmailChange,
+ label = "Correo",
+ placeholder = "correo@gmail.com"
+ )
+ // Campo: Contraseña
+ AuthPasswordText(
+ value = state.password,
+ onValueChange = onPasswordChange,
+ label = "Contraseña",
+ placeholder = "********"
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ //Errores de FB
+ if (state.firebaseError.isNotEmpty()) {
+ Text(
+ text = state.firebaseError, style = MaterialTheme.typography.bodySmall.copy(
+ color = MaterialTheme.colorScheme.error
+ )
+ )
+ }
+ if (state.isLoading) {
+ CircularProgressIndicator()
+ } else {
+ RumboButton(
+ text = "Registrarse",
+ onClick = onRegister,
+ style = RumboButtonStyle.Primary,
+ enabled = isFormValid,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+ }
+}
-@Preview(
- showBackground = true, name = "antalla Registro demostración ", backgroundColor = 0xFF121212
-)
+@Preview(showBackground = true)
@Composable
-private fun SignUpScreenPreview() {
+fun SignUpScreenPreview() {
RumboTheme(darkTheme = true) {
- SignUpScreen(controller = rememberNavController())
+ SignUpScreen(rememberNavController())
}
}
+
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 9282a36..851adc7 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,77 +1,206 @@
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.ui.tooling.preview.Preview
+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 androidx.navigation.compose.rememberNavController
+import com.appnotresponding.rumbo.R
+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.theme.RumboTheme
-
+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 ) {
- val currentUser = sampleUser.copy(name = "Ana")
+fun ChatListScreen(
+ controller: NavHostController,
+ userViewModel: UserViewModel,
+ chatViewModel: ChatViewModel,
+ placesViewModel: PlacesViewModel
+) {
+ val userState by userViewModel.currentUserState.collectAsState()
+ val currentUser = userState ?: sampleUser.copy(name = "Cargando...")
+ val chatState by chatViewModel.uiState.collectAsState()
+ val placesState by placesViewModel.uiState.collectAsState()
+
+ LaunchedEffect(placesState.itinerary) {
+ chatViewModel.listenToGroupChats(placesState.itinerary)
+ }
+
+ val myUid = FirebaseAuth.getInstance().currentUser?.uid ?: ""
- val mockChats = listOf(
- ChatPreviewData(
- sampleUser.copy(name = "Brandon"),
- "¡Ya estoy cerca! ...",
- "Rumbo al Museo Nacional",
- "",
- true
- ),
- ChatPreviewData(
- sampleUser.copy(name = "Aylean"),
- "¿Nos vemos allá?",
- "Rumbo al Museo Nacional",
- "",
- false
- ),
- ChatPreviewData(
- sampleUser.copy(name = "Ahbdul"),
- "¡Ya estoy cerca! ...",
- "Rumbo al Museo Nacional",
- "",
- false
- ),
- ChatPreviewData(
- sampleUser.copy(name = "Los Mochileros"),
- "@Ana, dónde estás?!",
- "Rumbo al Museo N...",
- "",
- true
- ),
- ChatPreviewData(sampleUser.copy(name = "Kyle"), "Fué un gusto conocerte!", null, "", false),
- ChatPreviewData(sampleUser.copy(name = "Ashley"), "¡Ya estoy cerca! ...", null, "", false),
- ChatPreviewData(sampleUser.copy(name = "Tatiana"), "¡Ya estoy cerca! ...", null, "", false)
- )
+ Scaffold(
+ contentWindowInsets = WindowInsets(0),
+ topBar = {
+ MainTopBar(u = currentUser, controller = controller)
+ },
+ 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
+ )
+ }
- ChatTemplate(
- currentUser = currentUser,
- title = "Chats",
- subtitle = "Ubicación actual: Bogotá",
- controller = controller
- ) {
- 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,
+ isOnline = convo.isOtherUserOnline
+ )
+ 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 groupUser = User(
+ name = group.placeName,
+ profilePictureUrl = group.placePhotoUrl
+ )
+ ChatListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ chatViewModel.selectGroupChat(
+ placeId = group.placeId,
+ placeName = group.placeName,
+ photoUrl = group.placePhotoUrl
+ )
+ controller.navigate(AppScreens.ChatThread.name)
+ },
+ user = groupUser,
+ lastMessage = group.lastMessage,
+ 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())
+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 13afc79..ab381f1 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,369 @@
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
+import com.appnotresponding.rumbo.ui.utils.rememberMediaHardwareManager
@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 = "9:00AM - 11:00AM",
- price = "$ 40.000 COP"
- )
+ val chatId = chatState.selectedChatId
+ val isGroup = chatState.isGroupChat
+ var unreadDividerTimestamp by remember(chatId) { mutableStateOf(null) }
+
+ val mediaManager = rememberMediaHardwareManager()
+
+ LaunchedEffect(mediaManager.imageUri) {
+ mediaManager.imageUri?.let { uri ->
+ chatThreadViewModel.sendMediaMessage(
+ chatId,
+ currentUser.name,
+ uri,
+ isGroup,
+ "image"
+ )
+
+ mediaManager.clearImage()
+ }
+ }
+
+ var mediaRecorder by remember { mutableStateOf(null) }
+ var audioFile by remember { mutableStateOf(null) }
+ var isRecording by remember { mutableStateOf(false) }
+ var pendingCameraUri by remember { mutableStateOf(null) }
+ var imagePreviewUrl by remember { mutableStateOf(null) }
+
+ val context = LocalContext.current
+
+ val imagePickerLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ if (uri != null) {
+ chatThreadViewModel.sendMediaMessage(chatId, currentUser.name, uri, isGroup, "image")
+ }
+ }
+
+ 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
+ }
+
+ LaunchedEffect(chatId) {
+ if (chatId.isNotBlank()) {
+ if (isGroup) {
+ chatThreadViewModel.listenToGroupMessages(chatId)
+ } else {
+ chatThreadViewModel.listenToMessages(chatId)
+ }
+ }
+ }
- 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)
+ 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 && threadState.otherUserIsOnline,
+ 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)
- }
-}
+ 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 = {
+ mediaManager.launchCamera()
+ },
+ 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
-@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 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
+ )
+ }
+ }
+ }
- val museoNacional = samplePlace.copy(
- name = "Museo Nacional", openHours = "9:00AM - 11:00AM", 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
- )
- )
-
- 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/itinerary/ItineraryScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/itinerary/ItineraryScreen.kt
index 398bd6e..eea7154 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/itinerary/ItineraryScreen.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/itinerary/ItineraryScreen.kt
@@ -1,18 +1,23 @@
package com.appnotresponding.rumbo.ui.screens.itinerary
import androidx.compose.runtime.Composable
-import androidx.navigation.NavController
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.navigation.NavHostController
-import androidx.navigation.compose.rememberNavController
-import com.appnotresponding.rumbo.models.samplePlace
import com.appnotresponding.rumbo.models.sampleUser
import com.appnotresponding.rumbo.ui.templates.ItineraryTemplate
+import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel
+import com.appnotresponding.rumbo.ui.viewModel.UserViewModel
@Composable
-fun ItineraryScreen(controller: NavHostController){
+fun ItineraryScreen(
+ controller: NavHostController, placesViewModel: PlacesViewModel, userViewModel: UserViewModel
+) {
+ val state by placesViewModel.uiState.collectAsState()
+ val userState by userViewModel.currentUserState.collectAsState()
+ val user = userState ?: sampleUser.copy(name = "Cargando...")
+
ItineraryTemplate(
- user = sampleUser,
- itineraryList = listOf(samplePlace, samplePlace, samplePlace),
- controller = controller
+ user = user, itineraryList = state.itinerary, controller = controller, placesViewModel = placesViewModel
)
-}
\ No newline at end of file
+}
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 ec2a4c8..dda4e30 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
@@ -1,14 +1,33 @@
package com.appnotresponding.rumbo.ui.screens.map
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.navigation.NavHostController
import com.appnotresponding.rumbo.models.sampleUser
-import com.appnotresponding.rumbo.ui.components.organisms.chat.ChatPreviewData
import com.appnotresponding.rumbo.ui.templates.MapTemplate
+import com.appnotresponding.rumbo.ui.viewModel.FriendsViewModel
+import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel
+import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel
+import com.appnotresponding.rumbo.ui.viewModel.UserViewModel
@Composable
fun MapScreen(
- controller: NavHostController
-){
- MapTemplate(sampleUser.copy(name = "Ana"), controller)
-}
\ No newline at end of file
+ controller: NavHostController,
+ placesViewModel: PlacesViewModel,
+ locationViewModel: UserLocationViewModel,
+ userViewModel: UserViewModel,
+ friendsViewModel: FriendsViewModel
+) {
+ val userState by userViewModel.currentUserState.collectAsState()
+ val user = userState ?: sampleUser.copy(name = "Cargando...")
+
+ MapTemplate(
+ user = user,
+ controller = controller,
+ placesViewModel = placesViewModel,
+ locationViewModel = locationViewModel,
+ userViewModel = userViewModel,
+ friendsViewModel = friendsViewModel
+ )
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/onboarding/OnBoardingScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/onboarding/OnBoardingScreen.kt
index fe1e7fa..4f3716c 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/onboarding/OnBoardingScreen.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/onboarding/OnBoardingScreen.kt
@@ -2,16 +2,17 @@ package com.appnotresponding.rumbo.ui.screens.onboarding
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
-import com.appnotresponding.rumbo.models.sampleUser
import com.appnotresponding.rumbo.navigation.AppScreens
-import com.appnotresponding.rumbo.ui.templates.MapTemplate
import com.appnotresponding.rumbo.ui.templates.OnboardingTemplate
@Composable
fun OnBoardingScreen(
controller: NavHostController
-){
+) {
OnboardingTemplate {
- controller.navigate(AppScreens.Map.name)
+ controller.navigate(AppScreens.Map.name) {
+ popUpTo(AppScreens.Splash.name) { inclusive = true }
+ launchSingleTop = true
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/plan/PlanScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/plan/PlanScreen.kt
index bc4e50c..cf80d3b 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/plan/PlanScreen.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/plan/PlanScreen.kt
@@ -1,17 +1,67 @@
package com.appnotresponding.rumbo.ui.screens.plan
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
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.templates.PlanTemplate
+import com.appnotresponding.rumbo.ui.utils.searchNearbyPlaces
+import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel
+import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel
+import com.appnotresponding.rumbo.ui.viewModel.UserViewModel
@Composable
-fun PlanScreen(controller: NavHostController){
+fun PlanScreen(
+ controller: NavHostController,
+ placesViewModel: PlacesViewModel,
+ locationViewModel: UserLocationViewModel,
+ userViewModel: UserViewModel
+) {
+ val context = LocalContext.current
+
+ val userLocationState by locationViewModel.uiState.collectAsState()
+ val placesState by placesViewModel.uiState.collectAsState()
+ val userState by userViewModel.currentUserState.collectAsState()
+ val user = userState ?: sampleUser.copy(name = "Cargando...")
+
+ LaunchedEffect(
+ userLocationState.latitude, userLocationState.longitude
+ ) {
+
+ val latitude = userLocationState.latitude
+ val longitude = userLocationState.longitude
+
+ // Evitar llamadas con coordenadas inválidas
+ if (latitude != 0.0 && longitude != 0.0) {
+
+ searchNearbyPlaces(
+ latitude = latitude, longitude = longitude,
+
+ onPlacesReceived = { places ->
+
+ placesViewModel.updatePlaces(
+ places
+ )
+ },
+
+ onError = { error ->
+
+ println("ERROR PLACES: $error")
+ },
+
+ context = context
+ )
+ }
+ }
+
PlanTemplate(
- user = sampleUser,
- placesList = listOf(samplePlace, samplePlace, samplePlace), // Simulamos una lista con 3 lugares
- controller = controller
+ user = user,
+ placesList = placesState.availablePlaces,
+ controller = controller,
+ placesViewModel = placesViewModel
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/profile/ProfileScreen.kt
new file mode 100644
index 0000000..5cba043
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/profile/ProfileScreen.kt
@@ -0,0 +1,98 @@
+package com.appnotresponding.rumbo.ui.screens.profile
+
+import android.net.Uri
+import android.util.Log
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavHostController
+import com.appnotresponding.rumbo.auth
+import com.appnotresponding.rumbo.navigation.AppScreens
+import com.appnotresponding.rumbo.ui.templates.ProfileTemplate
+import com.appnotresponding.rumbo.ui.utils.MyApp.Companion.fcmToken
+import com.appnotresponding.rumbo.ui.viewModel.ProfileViewModel
+import com.appnotresponding.rumbo.ui.viewModel.UserViewModel
+import com.google.firebase.Firebase
+import com.google.firebase.database.FirebaseDatabase
+import com.google.firebase.messaging.messaging
+
+@Composable
+fun ProfileScreen(
+ controller: NavHostController,
+ userViewModel: UserViewModel,
+ profileViewModel: ProfileViewModel = viewModel()
+) {
+ val userState by userViewModel.currentUserState.collectAsState()
+ val profileState by profileViewModel.uiState.collectAsState()
+ var selectedPhotoUri by remember { mutableStateOf(null) }
+
+ val imagePicker = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent()
+ ) { uri ->
+ selectedPhotoUri = uri
+ }
+
+ val user = userState
+ if (user == null) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
+ }
+ return
+ }
+
+ LaunchedEffect(user.id) {
+ profileViewModel.loadItineraryHistory(user.id)
+ profileViewModel.loadUserDropNotes(user.id)
+ }
+
+ ProfileTemplate(
+ user = user,
+ itineraryHistory = profileState.itineraryHistory,
+ dropNotes = profileState.userDropNotes,
+ selectedPhotoUri = selectedPhotoUri,
+ isSavingProfile = profileState.isSavingProfile,
+ profileError = profileState.profileError,
+ profileSuccess = profileState.profileSuccess,
+ controller = controller,
+ onBackClick = { controller.popBackStack() },
+ onPickPhoto = { imagePicker.launch("image/*") },
+ onSaveProfile = { name, lastname, phone ->
+ profileViewModel.updateProfile(
+ user = user,
+ name = name,
+ lastname = lastname,
+ phone = phone,
+ photoUri = selectedPhotoUri,
+ onSuccess = { selectedPhotoUri = null })
+ },
+ onSignOut = {
+ Firebase.messaging.token.addOnSuccessListener { token ->
+ fcmToken = token
+ FirebaseDatabase.getInstance().getReference("tokens/" + auth.currentUser!!.uid)
+ .setValue(null).addOnSuccessListener {
+ Log.i("FirebaseApp", "Token guardado correctamente")
+ auth.signOut()
+ }.addOnFailureListener { e ->
+ Log.e("FirebaseApp", "Error guardando token: ${e.message}")
+ auth.signOut()
+ }
+ }
+ controller.navigate(AppScreens.Splash.name) {
+ popUpTo(controller.graph.startDestinationId) { inclusive = true }
+ launchSingleTop = true
+ }
+ })
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/splash/SplashScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/splash/SplashScreen.kt
index b6326b5..29720b2 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/splash/SplashScreen.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/splash/SplashScreen.kt
@@ -24,10 +24,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.appnotresponding.rumbo.R
+import com.appnotresponding.rumbo.auth
import com.appnotresponding.rumbo.navigation.AppScreens
import com.appnotresponding.rumbo.ui.components.atoms.RumboButton
import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle
@@ -63,6 +63,13 @@ fun SplashScreen(
LaunchedEffect(Unit) {
delay(800)
ctaVisible = true
+ auth.currentUser?.let {
+ controller.navigate(AppScreens.Map.name) {
+ popUpTo(AppScreens.Splash.name) { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+
}
Box(
@@ -82,15 +89,11 @@ fun SplashScreen(
// Botones animados en la parte inferior
AnimatedVisibility(
visible = ctaVisible,
- enter = fadeIn(animationSpec = tween(500)) +
- slideInVertically(
- animationSpec = tween(500),
- initialOffsetY = { it / 2 }
- ),
+ enter = fadeIn(animationSpec = tween(500)) + slideInVertically(
+ animationSpec = tween(500), initialOffsetY = { it / 2 }),
modifier = Modifier
.align(Alignment.BottomCenter)
- .fillMaxWidth()
- ) {
+ .fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -99,13 +102,13 @@ fun SplashScreen(
) {
RumboButton(
text = "Iniciar Sesión",
- onClick = {controller.navigate(AppScreens.LogIn.name)},
+ onClick = { controller.navigate(AppScreens.LogIn.name) },
modifier = Modifier.fillMaxWidth()
)
RumboButton(
text = "Registrarse",
style = RumboButtonStyle.Secondary,
- onClick = {controller.navigate(AppScreens.SignUp.name)},
+ onClick = { controller.navigate(AppScreens.SignUp.name) },
modifier = Modifier.fillMaxWidth()
)
}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/AuthTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/AuthTemplate.kt
index 9aab526..6429743 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/AuthTemplate.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/AuthTemplate.kt
@@ -4,13 +4,14 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
@@ -26,22 +27,19 @@ fun AuthTemplate(
modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit
) {
- val highlightGreen = Color(0xFFC4F031)
- val darkBackground = Color(0xFF151515)
-
-
val backgroundBrush = Brush.radialGradient(
colors = listOf(
- highlightGreen.copy(alpha = 0.35f), darkBackground, Color.Black
- ), center = Offset(x = 100f, y = 800f), radius = 1200f
+ MaterialTheme.colorScheme.primary,
+ MaterialTheme.colorScheme.primary.copy(0f),
+ ), center = Offset(x = 100f, y = 800f), radius = 600f
)
Box(
modifier = modifier
- .fillMaxSize()
+ .fillMaxSize().imePadding()
+ .background(MaterialTheme.colorScheme.background)
.background(brush = backgroundBrush)
- .padding(24.dp),
- contentAlignment = Alignment.Center
+ .padding(24.dp), contentAlignment = Alignment.Center
) {
content()
}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatTemplate.kt
index b5bed0e..4ee6c8a 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatTemplate.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatTemplate.kt
@@ -13,7 +13,6 @@ import androidx.compose.runtime.Composable
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.User
import com.appnotresponding.rumbo.ui.components.organisms.common.MainTopBar
import com.appnotresponding.rumbo.ui.components.organisms.common.Nav
@@ -29,9 +28,8 @@ fun ChatTemplate(
) {
Scaffold(
contentWindowInsets = WindowInsets(0),
- topBar = { MainTopBar(u = currentUser) },
- bottomBar = { Nav(controller) }
- ) { paddingValues ->
+ topBar = { MainTopBar(u = currentUser, controller = controller) },
+ bottomBar = { Nav(controller) }) { paddingValues ->
Column(
modifier = modifier
.fillMaxSize()
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..ecf74bf 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,8 @@ 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.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
@@ -11,34 +13,64 @@ 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
+ .imePadding()
+ .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..0d67b44
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt
@@ -0,0 +1,56 @@
+package com.appnotresponding.rumbo.ui.templates
+
+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.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,
+ controller: NavHostController,
+ content: @Composable () -> Unit
+) {
+ Scaffold(
+ contentWindowInsets = WindowInsets(0),
+ topBar = { MainTopBar(u = currentUser, controller = controller) },
+ 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/ItineraryTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ItineraryTemplate.kt
index 12dd17c..6c2ecdd 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ItineraryTemplate.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ItineraryTemplate.kt
@@ -1,27 +1,25 @@
package com.appnotresponding.rumbo.ui.templates
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
-import androidx.navigation.compose.rememberNavController
import com.appnotresponding.rumbo.models.Place
import com.appnotresponding.rumbo.models.User
-import com.appnotresponding.rumbo.models.samplePlace
-import com.appnotresponding.rumbo.models.sampleUser
import com.appnotresponding.rumbo.ui.components.molecules.common.DayHeader
import com.appnotresponding.rumbo.ui.components.organisms.common.MainTopBar
import com.appnotresponding.rumbo.ui.components.organisms.common.Nav
import com.appnotresponding.rumbo.ui.components.organisms.itinerary.ItineraryOverview
-import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel
/**
*
@@ -35,13 +33,16 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme
*/
@Composable
-fun ItineraryTemplate(user: User, itineraryList: List,
- controller: NavHostController) {
+fun ItineraryTemplate(
+ user: User,
+ itineraryList: List,
+ controller: NavHostController,
+ placesViewModel: PlacesViewModel
+) {
Scaffold(
contentWindowInsets = WindowInsets(0),
- topBar = { MainTopBar(u = user) },
- bottomBar = { Nav(controller) }
- ) { paddingValues ->
+ topBar = { MainTopBar(u = user, controller = controller) },
+ bottomBar = { Nav(controller) }) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
@@ -50,11 +51,13 @@ fun ItineraryTemplate(user: User, itineraryList: List,
DayHeader(title = "Así Se Ve Tu Día")
Spacer(modifier = Modifier.height(16.dp))
- ItineraryOverview(itineraryList = itineraryList)
+ Box(modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp)) {
+ ItineraryOverview(itineraryList = itineraryList, placesViewModel, controller)}
}
}
}
+/**
@Preview(showBackground = true, name = "ItineraryTemplate - Light")
@Composable
private fun ItineraryTemplateLightPreview() {
@@ -77,4 +80,5 @@ private fun ItineraryTemplateDarkPreview() {
controller = rememberNavController()
)
}
-}
\ No newline at end of file
+}
+ */
\ No newline at end of file
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 b3821eb..45f8a5f 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
@@ -1,121 +1,762 @@
package com.appnotresponding.rumbo.ui.templates
-import androidx.compose.foundation.Image
+import android.util.Log
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.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.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 kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+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
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableDoubleStateOf
+import androidx.compose.runtime.mutableIntStateOf
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.Color
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
-import androidx.navigation.compose.rememberNavController
+import coil3.ImageLoader
+import coil3.request.ImageRequest
+import coil3.request.SuccessResult
+import coil3.request.allowHardware
+import coil3.toBitmap
import com.appnotresponding.rumbo.R
+import com.appnotresponding.rumbo.isDarkTheme
+import com.appnotresponding.rumbo.models.DropNote
import com.appnotresponding.rumbo.models.User
import com.appnotresponding.rumbo.models.samplePlace
import com.appnotresponding.rumbo.models.sampleReview
-import com.appnotresponding.rumbo.models.sampleUser
+import com.appnotresponding.rumbo.roadManager
+import com.appnotresponding.rumbo.ui.components.atoms.Avatar
+import com.appnotresponding.rumbo.ui.components.molecules.map.CancelRoute
+import com.appnotresponding.rumbo.ui.components.molecules.map.DropNoteBubble
import com.appnotresponding.rumbo.ui.components.molecules.map.LocateMe
+import com.appnotresponding.rumbo.ui.components.molecules.map.ToggleHeatmap
import com.appnotresponding.rumbo.ui.components.molecules.map.WriteDropNote
import com.appnotresponding.rumbo.ui.components.organisms.common.MainTopBar
import com.appnotresponding.rumbo.ui.components.organisms.common.Nav
import com.appnotresponding.rumbo.ui.components.organisms.map.DropNoteComposer
import com.appnotresponding.rumbo.ui.components.organisms.map.PlacePreviewCard
-import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import com.appnotresponding.rumbo.ui.components.organisms.map.ViewDropNote
+import com.appnotresponding.rumbo.ui.utils.SensorOverlay
+import com.appnotresponding.rumbo.ui.utils.createLocationRequest
+import com.appnotresponding.rumbo.ui.utils.rememberLocationManager
+import com.appnotresponding.rumbo.ui.utils.rememberMediaHardwareManager
+import com.appnotresponding.rumbo.ui.viewModel.DropNoteViewModel
+import com.appnotresponding.rumbo.ui.viewModel.ItineraryHistoryViewModel
+import com.appnotresponding.rumbo.ui.viewModel.MapViewModel
+import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel
+import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.google.accompanist.permissions.shouldShowRationale
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.maps.GoogleMap
+import com.google.android.gms.maps.GoogleMapOptions
+import com.google.android.gms.maps.model.CameraPosition
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps.model.MapColorScheme
+import com.google.maps.android.compose.GoogleMap
+import com.google.maps.android.compose.MapEffect
+import com.google.maps.android.compose.MapUiSettings
+import com.google.maps.android.compose.Marker
+import com.google.maps.android.compose.MarkerComposable
+import com.google.maps.android.compose.Polyline
+import com.google.maps.android.compose.rememberCameraPositionState
+import com.google.maps.android.compose.rememberUpdatedMarkerState
+import com.google.maps.android.compose.TileOverlay
+import com.google.maps.android.heatmaps.Gradient
+import com.google.maps.android.heatmaps.HeatmapTileProvider
+import androidx.compose.ui.graphics.toArgb
+import com.appnotresponding.rumbo.ui.components.molecules.map.ExpandableFAB
+
+import org.osmdroid.util.GeoPoint
+
+val locationPermission = android.Manifest.permission.ACCESS_FINE_LOCATION
+var locationRequest: LocationRequest = createLocationRequest()
+
+fun calculateRadiusForZoom(zoom: Float): Double {
+ return when {
+ zoom >= 18f -> 100.0
+ zoom >= 16f -> 500.0
+ zoom >= 14f -> 1500.0
+ zoom >= 12f -> 5000.0
+ zoom >= 10f -> 15000.0
+ else -> 50000.0
+ }
+}
+@OptIn(ExperimentalPermissionsApi::class)
@Composable
-fun MapTemplate(user: User,
- controller: NavHostController) {
+fun MapTemplate(
+ user: User,
+ controller: NavHostController,
+ viewModel: MapViewModel = viewModel(),
+ dropNoteViewModel: DropNoteViewModel = viewModel(),
+ itineraryHistoryViewModel: ItineraryHistoryViewModel = viewModel(),
+ placesViewModel: PlacesViewModel,
+ locationViewModel: UserLocationViewModel,
+ userViewModel: UserViewModel,
+ friendsViewModel: FriendsViewModel
+) {
+ Log.d("RECOMPOSE", "MapTemplate recomposed")
+
+ val context = LocalContext.current
+
+ val state by viewModel.uiState.collectAsState()
+ 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) }
+ var popupStateReviewComposer by remember { mutableStateOf(false) }
+ val currentPreviewedPlace = placesState.previewedPlace
+ val popupStateReview = currentPreviewedPlace != null
+ var popupStateViewDN by remember { mutableStateOf(false) }
+ var selectedDropNote by remember { mutableStateOf(null) }
+ val locationState = rememberLocationManager()
+ val mediaManager = rememberMediaHardwareManager()
+ var noteText by remember { mutableStateOf("") }
+ var reviewText by remember { mutableStateOf("") }
+ var reviewRating by remember { mutableStateOf(0f) }
+ var isUploadingReview by remember { mutableStateOf(false) }
+ var showReplaceRouteDialog by remember { mutableStateOf(false) }
+
+ val isWithinProximity = remember(userLocationState.latitude, userLocationState.longitude, currentPreviewedPlace) {
+ if (currentPreviewedPlace != null && userLocationState.latitude != 0.0 && userLocationState.longitude != 0.0) {
+ val userLatLng = LatLng(userLocationState.latitude, userLocationState.longitude)
+ val placeLatLng = LatLng(currentPreviewedPlace.latitude, currentPreviewedPlace.longitude)
+ val distance = com.google.maps.android.SphericalUtil.computeDistanceBetween(userLatLng, placeLatLng)
+ distance <= 100.0
+ } else {
+ false
+ }
+ }
+
+ val markerKey = remember(user.profilePictureUrl) { user.profilePictureUrl ?: "" }
+ var profileBitmap by remember(user.profilePictureUrl) { mutableStateOf(null) }
+ LaunchedEffect(user.profilePictureUrl) {
+ if (user.profilePictureUrl.isNullOrEmpty()) return@LaunchedEffect
+ val request =
+ ImageRequest.Builder(context).data(user.profilePictureUrl).allowHardware(false).build()
+ val result = ImageLoader(context).execute(request)
+ if (result is SuccessResult) {
+ profileBitmap = result.image.toBitmap().asImageBitmap()
+ }
+ }
+
+ var latitude by remember { mutableDoubleStateOf(4.627293) }
+ var longitude by remember { mutableDoubleStateOf(-74.063228) }
+ val cameraPositionState = rememberCameraPositionState {
+ position = CameraPosition.fromLatLngZoom(LatLng(latitude, longitude), 15f)
+ }
+ var currentMapStyle by remember { mutableIntStateOf(MapColorScheme.FOLLOW_SYSTEM) }
+ val mapId = stringResource(R.string.map_id)
+
+ val heatmapTileProvider = remember(state.heatmapPoints) {
+ if (state.heatmapPoints.isNotEmpty()) {
+ try {
+ val colors = intArrayOf(
+ Color.Blue.toArgb(),
+ Color.Yellow.toArgb(),
+ Color.Red.toArgb()
+ )
+ val startPoints = floatArrayOf(0.2f, 0.6f, 1.0f)
+ val gradient = Gradient(colors, startPoints)
+ HeatmapTileProvider.Builder()
+ .data(state.heatmapPoints)
+ .gradient(gradient)
+ .radius(50) // Blur radius (range 10 to 50)
+ .build()
+ } catch (e: Exception) {
+ Log.e("MapTemplate", "Error building HeatmapTileProvider", e)
+ null
+ }
+ } else {
+ null
+ }
+ }
+ var permission = rememberPermissionState(locationPermission)
+ var showButton by remember { mutableStateOf(false) }
+ SideEffect {
+ if (!permission.status.isGranted) {
+ if (permission.status.shouldShowRationale) {
+ showButton = true
+ } else {
+ showButton = false
+ permission.launchPermissionRequest()
+ }
+ }
+ }
+ if (permission.status.isGranted) {
+ if (!locationViewModel.permissionGranted) locationViewModel.updateVel()
+ }
+
+ LaunchedEffect(
+ userLocationState.latitude, userLocationState.longitude, state.centerInUserFirstTime
+ ) {
+ Log.d("RECOMPOSE", "Enntrando en launch de location")
+ val tieneUbicacionReal = userLocationState.latitude != 0.0 || userLocationState.longitude != 0.0
+
+ if (tieneUbicacionReal) {
+ viewModel.updateUserMarker(userLocationState.latitude, userLocationState.longitude)
+ }
+
+ if (state.centerInUserFirstTime) {
+ if (tieneUbicacionReal && placesState.selectedPlace == null) {
+ cameraPositionState.position = CameraPosition.fromLatLngZoom(
+ LatLng(userLocationState.latitude, userLocationState.longitude), 18f
+ )
+ viewModel.updateCenterInUserFirstTime()
+ } else if (tieneUbicacionReal && placesState.selectedPlace != null) {
+ cameraPositionState.position = CameraPosition.fromLatLngZoom(
+ LatLng(
+ placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude
+ ), 14f
+ )
+ viewModel.updateCenterInUserFirstTime()
+ }
+ }
+ }
+
+ LaunchedEffect(placesState.selectedPlace) {
+ val tieneUbicacionReal = userLocationState.latitude != 0.0 || userLocationState.longitude != 0.0
+
+ if (tieneUbicacionReal && placesState.selectedPlace != null) {
+ val startPoint = GeoPoint(userLocationState.latitude, userLocationState.longitude)
+ val destination = GeoPoint(
+ placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude
+ )
+ val points = arrayListOf(startPoint, destination)
+ val routePoints = withContext(Dispatchers.IO) {
+ try {
+ val road = roadManager.getRoad(points)
+ road.mRouteHigh.map { geoPoint ->
+ LatLng(geoPoint.latitude, geoPoint.longitude)
+ }
+ } catch (e: Exception) {
+ emptyList()
+ }
+ }
+ if (routePoints.isNotEmpty()) {
+ viewModel.updateRoutePoints(routePoints)
+ }
+ }
+
+ if (placesState.selectedPlace != null) {
+ viewModel.updateAdditionalMarker(
+ LatLng(
+ placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude
+ ), placesState.selectedPlace!!.name
+ )
+ } else {
+ viewModel.updateRoutePoints(emptyList())
+ }
+ }
+
+ LaunchedEffect(isDarkTheme) {
+ currentMapStyle = if (isDarkTheme) MapColorScheme.DARK else MapColorScheme.LIGHT
+ }
+
+ LaunchedEffect(
+ user.id,
+ placesState.selectedPlace?.id,
+ userLocationState.latitude,
+ userLocationState.longitude
+ ) {
+ itineraryHistoryViewModel.markPlaceVisitedIfNeeded(
+ userId = user.id,
+ place = placesState.selectedPlace,
+ userLat = userLocationState.latitude,
+ userLng = userLocationState.longitude,
+ context = context
+ )
+ }
+
+ 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),
- topBar = { MainTopBar(user) },
+ topBar = { MainTopBar(user, controller = controller) },
floatingActionButton = {
Column(
modifier = Modifier
- .height(120.dp)
- .width(45.dp),
- verticalArrangement = Arrangement.spacedBy(30.dp)
+ .width(56.dp)
+ .padding(bottom = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom)
) {
- WriteDropNote {
- popupStateDNComposer = !popupStateDNComposer
+ if (permission.status.isGranted) {
+ if (placesState.selectedPlace != null) {
+ CancelRoute {
+ placesViewModel.clearForNavigation()
+ viewModel.updateRoutePoints(emptyList())
+ viewModel.cancelAdditionalMarkerVisibility()
+ }
+ }
+ ExpandableFAB(
+ isHeatmapActive = state.isHeatmapVisible,
+ onHeatmapClick = { viewModel.toggleHeatmap() },
+ onDropNoteClick = { popupStateDNComposer = !popupStateDNComposer },
+ onLocateMeClick = {
+ if (locationState.hasPermission) {
+ cameraPositionState.position =
+ CameraPosition.fromLatLngZoom(state.userMarker.position, 16f)
+ Log.d(
+ "MapTemplate",
+ "Ubicacion: ${locationState.latitude}, ${locationState.longitude}"
+ )
+ } else {
+ locationState.requestPermission()
+ }
+ },
+ isUserRouteActive = user.sharingLocation,
+ onUserRouteClick = {
+ userViewModel.toggleLocationSharing(!user.sharingLocation)
+ }
+ )
}
- LocateMe { }
}
},
bottomBar = { Nav(controller) }) { paddingValues ->
- // Main content area with the map
- Box(modifier = Modifier.fillMaxSize().padding(top = paddingValues.calculateTopPadding()/2)) {
- Image(
+ if (permission.status.isGranted) {
+ Box(
modifier = Modifier
.fillMaxSize()
- .clickable(onClick = { popupStateReview = !popupStateReview }),
- contentScale = ContentScale.FillHeight,
- painter = painterResource(R.mipmap.img_map),
- contentDescription = "Map"
- )
+ .padding(top = paddingValues.calculateTopPadding() / 2)
+ ) {
+ GoogleMap(
+ modifier = Modifier.fillMaxSize(),
+ cameraPositionState = cameraPositionState,
+ contentDescription = "Mapa de Rumbo",
+ onMapLongClick = { latLng ->
+ val currentZoom = cameraPositionState.position.zoom
+ val radius = calculateRadiusForZoom(currentZoom)
+ placesViewModel.searchAndShowNearestPlace(
+ latitude = latLng.latitude,
+ longitude = latLng.longitude,
+ radius = radius,
+ context = context
+ )
+ },
+ uiSettings = MapUiSettings(
+ zoomControlsEnabled = false,
+ myLocationButtonEnabled = false,
+ mapToolbarEnabled = false
+ ),
+ googleMapOptionsFactory = {
+ GoogleMapOptions().apply {
+ mapId(mapId)
+ mapType(GoogleMap.MAP_TYPE_NORMAL)
+ }
+ }) {
+ // https://medium.com/@ferobregon03/compose-multiplatform-displaying-and-updating-geojson-on-a-mapbox-96f025d8024a
+ // https://gitee.com/coolleizhu/android-maps-compose#obtaining-access-to-the-raw-googlemap-experimental
+ // https://googlemaps.github.io/android-maps-compose/maps-compose/com.google.maps.android.compose/-map-effect.html
+ MapEffect(currentMapStyle) { googleMap ->
+ googleMap.mapColorScheme = currentMapStyle
+ }
+ MarkerComposable(
+ state = rememberUpdatedMarkerState(
+ LatLng(
+ userLocationState.latitude, userLocationState.longitude
+ )
+ ), title = "${user.name} (Tú)"
+ ) {
+ 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,
+ visible = state.additionalMarkerVisible
+ )
+ if (state.routePoints.isNotEmpty()) {
+ Polyline(
+ points = state.routePoints,
+ color = MaterialTheme.colorScheme.primary,
+ width = 10f
+ )
+ }
+ if (state.userRouteVisible) {
+ Polyline(
+ points = state.userRoutePoints,
+ color = MaterialTheme.colorScheme.tertiary,
+ width = 10f
+ )
+ }
+
+ if (state.isHeatmapVisible && heatmapTileProvider != null) {
+ key(heatmapTileProvider) {
+ TileOverlay(
+ tileProvider = heatmapTileProvider
+ )
+ }
+ }
+
+
+
+ dropNoteState.dropNotes.forEach { note ->
+ val position = LatLng(note.latitude, note.longitude)
+ val author = dropNoteState.dropNoteAuthors[note.creatorId]
+ //developer.android.com/reference/kotlin/androidx/compose/runtime/key.composable#key(kotlin.Array,kotlin.Function0)
+ // key fuerza rerenderizado cuando el autor llega
+ key(note.id, author?.profilePictureUrl) {
+ MarkerComposable(
+ state = rememberUpdatedMarkerState(position),
+ title = "DropNote de ${author?.name ?: ""}",
+ onClick = {
+ selectedDropNote = note
+ popupStateViewDN = true
+ true
+ }) {
+ DropNoteBubble(
+ modifier = Modifier.size(48.dp), d = note, author = author
+ )
+ }
+ }
+ }
+ }
+ SensorOverlay(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(top = 64.dp)
+ .padding(16.dp)
+ )
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(15.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ var message =
+ "No se puede acceder a esta funcionalidad sin el permiso de localización"
+ if (showButton) {
+ message =
+ "Esta función le permite visualizar un mapa para ver rutas. Es indispensable que permita el acceso."
+
+ Spacer(modifier = Modifier.height(25.dp))
+ Text(
+ message, textAlign = TextAlign.Center, fontSize = 15.sp
+ )
+ Spacer(modifier = Modifier.height(25.dp))
+ Button(
+ modifier = Modifier.fillMaxWidth(), onClick = {
+ permission.launchPermissionRequest()
+ }) { Text("Solicitar Permiso") }
+ } else {
+
+ Spacer(modifier = Modifier.height(25.dp))
+ Text(
+ message, textAlign = TextAlign.Center, fontSize = 15.sp
+ )
+ }
+ }
}
}
if (popupStateDNComposer) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(Color.Black.copy(0.75f))
- .padding(16.dp),
- contentAlignment = Alignment.Center
+ Dialog(
+ onDismissRequest = {
+ if (!dropNoteState.isUploadingNote) {
+ popupStateDNComposer = false
+ }
+ }, properties = DialogProperties(
+ usePlatformDefaultWidth = false
+ )
) {
- DropNoteComposer()
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.55f))
+ .padding(20.dp), contentAlignment = Alignment.Center
+ ) {
+ if (dropNoteState.isUploadingNote) {
+ androidx.compose.material3.CircularProgressIndicator(
+ color = MaterialTheme.colorScheme.primary
+ )
+ } else {
+ DropNoteComposer(
+ value = noteText, onValueChange = { noteText = it },
+
+ onImageClick = {
+ mediaManager.launchCamera()
+ },
+
+ onGalleryClick = {
+ mediaManager.launchGallery()
+ },
+
+ onSendClick = {
+ if (noteText.isNotBlank() || mediaManager.imageUri != null) {
+ val tieneUbicacion =
+ userLocationState.latitude != 0.0 || userLocationState.longitude != 0.0
+ val lat =
+ if (tieneUbicacion) userLocationState.latitude else 4.627293
+ val lng =
+ if (tieneUbicacion) userLocationState.longitude else -74.063228
+
+ dropNoteViewModel.uploadAndSaveDropNote(
+ content = noteText,
+ imageUri = mediaManager.imageUri,
+ latitude = lat,
+ longitude = lng,
+ creatorId = user.id,
+ onSuccess = {
+ noteText = ""
+ mediaManager.clearImage()
+ popupStateDNComposer = false
+ })
+ }
+ },
+
+ imageUri = mediaManager.imageUri
+ )
+ }
+ }
}
}
- if (popupStateReview) {
+ if (popupStateReview && currentPreviewedPlace != null) {
Box(
modifier = Modifier
.fillMaxSize()
+ .clickable(
+ interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
+ indication = null,
+ onClick = { placesViewModel.showPreview(null) }
+ )
.padding(16.dp)
.offset(y = -(90).dp),
contentAlignment = Alignment.BottomCenter
) {
- PlacePreviewCard(place = samplePlace, reviews = listOf(sampleReview))
+ Box(modifier = Modifier.clickable(
+ interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
+ indication = null,
+ onClick = {}
+ )) {
+ val isInItinerary = placesState.itinerary.any { it.id == currentPreviewedPlace.id }
+ PlacePreviewCard(
+ place = currentPreviewedPlace,
+ reviews = currentPreviewedPlace.reviews,
+ isInItinerary = isInItinerary,
+ isReviewEnabled = isWithinProximity,
+ onNavigateClick = {
+ if (placesState.selectedPlace != null && placesState.selectedPlace?.id != currentPreviewedPlace.id) {
+ showReplaceRouteDialog = true
+ } else {
+ placesViewModel.selectForNavigation(currentPreviewedPlace)
+ placesViewModel.showPreview(null)
+ }
+ },
+ onAddToItineraryClick = {
+ if (isInItinerary) {
+ placesViewModel.removeFromItinerary(currentPreviewedPlace)
+ } else {
+ placesViewModel.addToItinerary(currentPreviewedPlace)
+ }
+ },
+ onReviewClick = {
+ popupStateReviewComposer = true
+ }
+ )
+ }
}
}
-}
-
-@Preview(showBackground = true, name = "PlacePreviewCard - Light")
-@Composable
-private fun MapTemplateLightPreview() {
- RumboTheme(darkTheme = true) {
- MapTemplate(sampleUser,
- controller = rememberNavController())
+ if (popupStateReviewComposer && currentPreviewedPlace != null) {
+ Dialog(
+ onDismissRequest = {
+ if (!isUploadingReview) {
+ popupStateReviewComposer = false
+ reviewText = ""
+ reviewRating = 0f
+ mediaManager.clearImage()
+ }
+ }, properties = DialogProperties(
+ usePlatformDefaultWidth = false
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.55f))
+ .padding(20.dp), contentAlignment = Alignment.Center
+ ) {
+ com.appnotresponding.rumbo.ui.components.organisms.map.ReviewComposer(
+ rating = reviewRating,
+ onRatingChange = { reviewRating = it },
+ textValue = reviewText,
+ onTextChange = { reviewText = it },
+ isUploading = isUploadingReview,
+ imageUri = mediaManager.imageUri,
+ onImageClick = { mediaManager.launchCamera() },
+ onGalleryClick = { mediaManager.launchGallery() },
+ onSendClick = {
+ isUploadingReview = true
+ val newReview = com.appnotresponding.rumbo.models.Review(
+ id = "",
+ user = user,
+ rating = reviewRating,
+ text = reviewText,
+ time = System.currentTimeMillis()
+ )
+ placesViewModel.uploadAndSaveReview(
+ placeId = currentPreviewedPlace.id,
+ review = newReview,
+ imageUri = mediaManager.imageUri,
+ onSuccess = {
+ isUploadingReview = false
+ popupStateReviewComposer = false
+ reviewText = ""
+ reviewRating = 0f
+ mediaManager.clearImage()
+ // Volvemos a abrir la tarjeta para que se vea la reseña recién creada
+ placesViewModel.showPreview(currentPreviewedPlace)
+ },
+ onFailure = { error ->
+ isUploadingReview = false
+ Log.e("MapTemplate", "Error al subir reseña: $error")
+ }
+ )
+ }
+ )
+ }
+ }
}
-}
-
-@Preview(showBackground = true, backgroundColor = 0xFF1E1E1E, name = "PlacePreviewCard - Dark")
-@Composable
-private fun MapTemplateDarkPreview() {
- RumboTheme(darkTheme = false) {
- MapTemplate(sampleUser,
- controller = rememberNavController())
+ if (popupStateViewDN && selectedDropNote != null) {
+ Dialog(
+ onDismissRequest = {
+ popupStateViewDN = false
+ selectedDropNote = null
+ }) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Color.Transparent)
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ val author = dropNoteState.dropNoteAuthors[selectedDropNote!!.creatorId]
+ ?: User(id = selectedDropNote!!.creatorId, name = "Usuario")
+ ViewDropNote(
+ user = author,
+ content = selectedDropNote!!.content,
+ imageUrl = selectedDropNote!!.imageUrl,
+ timestamp = selectedDropNote!!.timestamp,
+ showDeleteOption = selectedDropNote!!.creatorId == user.id,
+ onDeleteClick = {
+ dropNoteViewModel.deleteDropNote(
+ noteId = selectedDropNote!!.id,
+ imageUrl = selectedDropNote!!.imageUrl,
+ onSuccess = {
+ popupStateViewDN = false
+ selectedDropNote = null
+ },
+ onFailure = { error ->
+ Log.e("MapTemplate", "Error al eliminar DropNote: $error")
+ })
+ })
+ }
+ }
+ }
+ if (showReplaceRouteDialog && currentPreviewedPlace != null) {
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = { showReplaceRouteDialog = false },
+ title = { Text("Ruta activa") },
+ text = { Text("Tienes una ruta activa en curso. ¿Deseas reemplazarla?") },
+ confirmButton = {
+ androidx.compose.material3.TextButton(
+ onClick = {
+ showReplaceRouteDialog = false
+ placesViewModel.selectForNavigation(currentPreviewedPlace)
+ placesViewModel.showPreview(null)
+ }
+ ) {
+ Text("Confirmar")
+ }
+ },
+ dismissButton = {
+ androidx.compose.material3.TextButton(
+ onClick = { showReplaceRouteDialog = false }
+ ) {
+ Text("Cancelar")
+ }
+ }
+ )
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/PlanTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/PlanTemplate.kt
index 7fe3ba0..7da3784 100644
--- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/PlanTemplate.kt
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/PlanTemplate.kt
@@ -9,19 +9,24 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
-import androidx.navigation.compose.rememberNavController
import com.appnotresponding.rumbo.models.Place
import com.appnotresponding.rumbo.models.User
-import com.appnotresponding.rumbo.models.samplePlace
-import com.appnotresponding.rumbo.models.sampleUser
import com.appnotresponding.rumbo.ui.components.molecules.common.LocationHeader
import com.appnotresponding.rumbo.ui.components.organisms.common.MainTopBar
import com.appnotresponding.rumbo.ui.components.organisms.common.Nav
import com.appnotresponding.rumbo.ui.components.organisms.plan.PlanPOIList
-import com.appnotresponding.rumbo.ui.theme.RumboTheme
+import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalContext
+import com.appnotresponding.rumbo.ui.components.atoms.RumboTextField
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.res.painterResource
+import androidx.compose.foundation.layout.fillMaxWidth
+import com.appnotresponding.rumbo.R
/**
*
@@ -35,13 +40,19 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme
*/
@Composable
-fun PlanTemplate(user: User, placesList: List,
- controller: NavHostController) {
+fun PlanTemplate(
+ user: User,
+ placesList: List,
+ controller: NavHostController,
+ placesViewModel: PlacesViewModel
+) {
+ val placesState by placesViewModel.uiState.collectAsState()
+ val context = LocalContext.current
+
Scaffold(
contentWindowInsets = WindowInsets(0),
- topBar = { MainTopBar(u = user) },
- bottomBar = { Nav(controller) }
- ) { paddingValues ->
+ topBar = { MainTopBar(u = user, controller = controller) },
+ bottomBar = { Nav(controller) }) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
@@ -49,33 +60,53 @@ fun PlanTemplate(user: User, placesList: List,
) {
LocationHeader(title = "Planea Tu Día", locationName = "Bogotá")
+ RumboTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ value = placesState.searchQuery,
+ onValueChange = { query ->
+ placesViewModel.onSearchQueryChanged(query, context)
+ },
+ placeholder = "Buscar atractivos turísticos...",
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_search),
+ contentDescription = "Icono de búsqueda",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ )
+
Spacer(modifier = Modifier.height(16.dp))
- PlanPOIList(places = placesList)
+ PlanPOIList(places = placesList, placesViewModel, controller)
}
}
}
+/**
@Preview(showBackground = true, name = "PlanTemplate - Light")
@Composable
private fun PlanTemplateLightPreview() {
- RumboTheme(darkTheme = false) {
- PlanTemplate(
- user = sampleUser,
- placesList = listOf(samplePlace, samplePlace, samplePlace),
- controller = rememberNavController()
- )
- }
+RumboTheme(darkTheme = false) {
+PlanTemplate(
+user = sampleUser,
+placesList = listOf(samplePlace, samplePlace, samplePlace),
+controller = rememberNavController()
+)
+}
}
@Preview(showBackground = true, name = "PlanTemplate - Dark", backgroundColor = 0xFF1E1E1E)
@Composable
private fun PlanTemplateDarkPreview() {
- RumboTheme(darkTheme = true) {
- PlanTemplate(
- user = sampleUser,
- placesList = listOf(samplePlace, samplePlace, samplePlace),
- controller = rememberNavController()
- )
- }
-}
\ No newline at end of file
+RumboTheme(darkTheme = true) {
+PlanTemplate(
+user = sampleUser,
+placesList = listOf(samplePlace, samplePlace, samplePlace),
+controller = rememberNavController()
+)
+}
+}
+ */
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ProfileTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ProfileTemplate.kt
new file mode 100644
index 0000000..7a350b1
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ProfileTemplate.kt
@@ -0,0 +1,475 @@
+package com.appnotresponding.rumbo.ui.templates
+
+import android.net.Uri
+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.WindowInsets
+import androidx.compose.foundation.layout.aspectRatio
+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.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.Edit
+import androidx.compose.material.icons.rounded.Event
+import androidx.compose.material.icons.rounded.Image
+import androidx.compose.material.icons.rounded.Logout
+import androidx.compose.material.icons.rounded.PhotoCamera
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.res.painterResource
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import coil3.compose.SubcomposeAsyncImage
+import com.appnotresponding.rumbo.R
+import com.appnotresponding.rumbo.models.DropNote
+import com.appnotresponding.rumbo.models.User
+import com.appnotresponding.rumbo.models.VisitedPlace
+import com.appnotresponding.rumbo.ui.components.atoms.Avatar
+import com.appnotresponding.rumbo.ui.components.atoms.AvatarSize
+import com.appnotresponding.rumbo.ui.components.atoms.RumboButton
+import com.appnotresponding.rumbo.ui.components.molecules.auth.AuthPhoneText
+import com.appnotresponding.rumbo.ui.components.molecules.auth.AuthPlainText
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+enum class ProfileMenu {
+ EditData, ItineraryHistory, Memories
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ProfileTemplate(
+ user: User,
+ itineraryHistory: Map>,
+ dropNotes: List,
+ selectedPhotoUri: Uri?,
+ isSavingProfile: Boolean,
+ profileError: String,
+ profileSuccess: String,
+ controller: androidx.navigation.NavHostController,
+ onBackClick: () -> Unit,
+ onPickPhoto: () -> Unit,
+ onSaveProfile: (name: String, lastname: String, phone: String) -> Unit,
+ onSignOut: () -> Unit
+) {
+ var selectedMenu by remember { mutableStateOf(ProfileMenu.EditData) }
+ var name by remember(user.id, user.name) { mutableStateOf(user.name) }
+ var lastname by remember(user.id, user.lastname) { mutableStateOf(user.lastname) }
+ var phone by remember(user.id, user.phone) { mutableStateOf(user.phone) }
+
+ LaunchedEffect(user.id, user.name, user.lastname, user.phone) {
+ name = user.name
+ lastname = user.lastname
+ phone = user.phone
+ }
+
+ Scaffold(
+ contentWindowInsets = WindowInsets(0),
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(bottom = paddingValues.calculateBottomPadding())
+ .verticalScroll(rememberScrollState())
+ ) {
+ ProfileHeader(
+ user = user,
+ selectedPhotoUri = selectedPhotoUri,
+ onBackClick = onBackClick,
+ onPickPhoto = onPickPhoto,
+ onSignOut = onSignOut
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ ProfileMenu.entries.forEach { menu ->
+ FilterChip(
+ shape = MaterialTheme.shapes.large,
+ selected = selectedMenu == menu,
+ onClick = { selectedMenu = menu },
+ label = {
+ Text(
+ text = when (menu) {
+ ProfileMenu.EditData -> "Datos"
+ ProfileMenu.ItineraryHistory -> "Historial"
+ ProfileMenu.Memories -> "Recuerdos"
+ }
+ )
+ },
+ leadingIcon = {
+ Icon(
+ painter = when (menu) {
+ ProfileMenu.EditData -> painterResource(R.drawable.ic_user)
+ ProfileMenu.ItineraryHistory -> painterResource(R.drawable.ic_list)
+ ProfileMenu.Memories -> painterResource(R.drawable.ic_recuerdos)
+ }, contentDescription = null, modifier = Modifier.size(18.dp)
+ )
+ })
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 24.dp)
+ ) {
+ when (selectedMenu) {
+ ProfileMenu.EditData -> EditProfileSection(
+ name = name,
+ lastname = lastname,
+ phone = phone,
+ onNameChange = { name = it },
+ onLastnameChange = { lastname = it },
+ onPhoneChange = { phone = it },
+ isSaving = isSavingProfile,
+ profileError = profileError,
+ profileSuccess = profileSuccess,
+ onSave = { onSaveProfile(name, lastname, phone) })
+
+ ProfileMenu.ItineraryHistory -> ItineraryHistorySection(itineraryHistory)
+ ProfileMenu.Memories -> MemoriesSection(dropNotes)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ProfileHeader(
+ user: User,
+ selectedPhotoUri: Uri?,
+ onBackClick: () -> Unit,
+ onPickPhoto: () -> Unit,
+ onSignOut: () -> Unit
+) {
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.large
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 32.dp, start = 16.dp, end = 16.dp, bottom = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBackClick) {
+ Icon(painter = painterResource(R.drawable.ic_arrow_left), contentDescription = "Volver")
+ }
+ IconButton(onClick = onSignOut) {
+ Icon(painter = painterResource(R.drawable.ic_logout), contentDescription = "Cerrar sesión")
+ }
+ }
+
+ Box(contentAlignment = Alignment.BottomEnd) {
+ if (selectedPhotoUri != null) {
+ AsyncImage(
+ model = selectedPhotoUri,
+ contentDescription = "Foto de perfil seleccionada",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(96.dp)
+ .clip(CircleShape)
+ )
+ } else {
+ Avatar(user = user, size = AvatarSize.Large, modifier = Modifier.size(96.dp))
+ }
+ Surface(
+ modifier = Modifier
+ .size(34.dp)
+ .clip(CircleShape)
+ .clickable(onClick = onPickPhoto),
+ color = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ shape = CircleShape
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ painter = painterResource(R.drawable.ic_camera),
+ contentDescription = "Cambiar foto",
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+ }
+
+ Text(
+ text = "${user.name} ${user.lastname}".trim().ifBlank { "Perfil" },
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = user.email,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+@Composable
+private fun EditProfileSection(
+ name: String,
+ lastname: String,
+ phone: String,
+ onNameChange: (String) -> Unit,
+ onLastnameChange: (String) -> Unit,
+ onPhoneChange: (String) -> Unit,
+ isSaving: Boolean,
+ profileError: String,
+ profileSuccess: String,
+ onSave: () -> Unit
+) {
+ ElevatedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)
+ ) {
+ Text(
+ text = "Editar datos personales",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ AuthPlainText(
+ value = name,
+ onValueChange = onNameChange,
+ label = "Nombres",
+ placeholder = "Tu nombre"
+ )
+ AuthPlainText(
+ value = lastname,
+ onValueChange = onLastnameChange,
+ label = "Apellidos",
+ placeholder = "Tus apellidos"
+ )
+ AuthPhoneText(value = phone, onValueChange = onPhoneChange)
+ if (profileError.isNotBlank()) {
+ Text(
+ text = profileError,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ if (profileSuccess.isNotBlank()) {
+ Text(
+ text = profileSuccess,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ RumboButton(
+ text = "Guardar cambios",
+ onClick = onSave,
+ modifier = Modifier.fillMaxWidth(),
+ loading = isSaving,
+ enabled = name.isNotBlank() && lastname.isNotBlank() && Regex("^\\+\\d{10,14}$").matches(
+ phone
+ )
+ )
+ }
+ }
+}
+
+@Composable
+private fun ItineraryHistorySection(
+ itineraryHistory: Map>
+) {
+ if (itineraryHistory.isEmpty()) {
+ EmptyState(text = "Aún no hay lugares visitados.")
+ return
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ itineraryHistory.toSortedMap(compareByDescending { it }).forEach { (day, places) ->
+ ElevatedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = formatDateHeader(day),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ val placeCount = places.size
+ Text(
+ text = "$placeCount lugar${if (placeCount > 1) "es" else ""}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ places.forEachIndexed { index, place ->
+ VisitedPlaceRow(place)
+ if (index < places.size - 1) {
+ Spacer(modifier = Modifier.height(10.dp))
+ HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f))
+ Spacer(modifier = Modifier.height(10.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun formatDateHeader(day: String): String {
+ return try {
+ val date = java.time.LocalDate.parse(day)
+ val formatter = DateTimeFormatter.ofPattern("d 'de' MMMM, yyyy", java.util.Locale("es", "ES"))
+ date.format(formatter)
+ } catch (e: Exception) {
+ day
+ }
+}
+
+@Composable
+private fun VisitedPlaceRow(place: VisitedPlace) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(64.dp)
+ .clip(MaterialTheme.shapes.medium)
+ .background(MaterialTheme.colorScheme.secondaryContainer),
+ contentAlignment = Alignment.Center
+ ) {
+ SubcomposeAsyncImage(
+ model = place.imageUrl,
+ contentDescription = "Imagen de ${place.placeName}",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ error = {
+ Image(
+ painter = painterResource(R.drawable.ic_picture),
+ contentDescription = null,
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondaryContainer),
+ modifier = Modifier.padding(16.dp)
+ )
+ })
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = place.placeName,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = place.address.ifBlank { "Sin dirección" },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = formatTimestamp(place.visitedAt),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+}
+
+@Composable
+private fun MemoriesSection(dropNotes: List) {
+ if (dropNotes.isEmpty()) {
+ EmptyState(text = "Aún no has creado DropNotes.")
+ return
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ dropNotes.forEach { note ->
+ ElevatedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Text(
+ text = formatTimestamp(note.timestamp),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ if (note.content.isNotBlank()) {
+ Text(
+ text = note.content,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ if (!note.imageUrl.isNullOrBlank()) {
+ AsyncImage(
+ model = note.imageUrl,
+ contentDescription = "Imagen del DropNote",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(16f / 9f)
+ .clip(MaterialTheme.shapes.medium)
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmptyState(text: String) {
+ ElevatedCard(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = text,
+ modifier = Modifier.padding(20.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+private fun formatTimestamp(timestamp: Long): String {
+ if (timestamp <= 0L) return "Sin fecha"
+ val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")
+ return Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).format(formatter)
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/AccelerometerManager.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/AccelerometerManager.kt
new file mode 100644
index 0000000..3057932
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/AccelerometerManager.kt
@@ -0,0 +1,59 @@
+package com.appnotresponding.rumbo.ui.utils
+
+import android.content.Context
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+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.platform.LocalContext
+import kotlin.math.abs
+import kotlin.math.sqrt
+
+data class AccelerometerState(
+ val isMoving: Boolean
+)
+
+@Composable
+fun rememberAccelerometerManager(context: Context = LocalContext.current): AccelerometerState {
+ var isMoving by remember { mutableStateOf(false) }
+
+ val sensorManager = remember {
+ context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
+ }
+
+ val accelerometerSensor = remember {
+ sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+ }
+
+ DisposableEffect(Unit) {
+ val listener = object : SensorEventListener {
+ override fun onSensorChanged(event: SensorEvent) {
+ val x = event.values[0]
+ val y = event.values[1]
+ val z = event.values[2]
+ // Calcul la magnitud del vector de aceleracion
+ val magnitude = sqrt(x * x + y * y + z * z)
+ // La gravedad es 9.8m/s2, si la diferencia supera el umbral el dispositivo se esta moviendo por lo que la gracvedad afecta al aceleromtro
+ isMoving = abs(magnitude - SensorManager.GRAVITY_EARTH) > 1.5f
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
+ }
+
+ sensorManager.registerListener(
+ listener, accelerometerSensor, SensorManager.SENSOR_DELAY_NORMAL
+ )
+
+ onDispose {
+ sensorManager.unregisterListener(listener)
+ }
+ }
+
+ return AccelerometerState(isMoving = isMoving)
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/CompassManager.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/CompassManager.kt
new file mode 100644
index 0000000..1ec5c16
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/CompassManager.kt
@@ -0,0 +1,122 @@
+package com.appnotresponding.rumbo.ui.utils
+
+import android.content.Context
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+
+data class CompassState(
+ val degrees: Float
+)
+@Composable
+fun rememberCompassManager(context: Context = LocalContext.current): CompassState {
+ var degrees by remember { mutableFloatStateOf(0f) }
+ val smoothingAlpha = 0.15f
+
+ val sensorManager = remember {
+ context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
+ }
+
+ val rotationVectorSensor = remember {
+ sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
+ }
+
+ val magnetometerSensor = remember {
+ sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
+ }
+
+ val accelerometerSensor = remember {
+ sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+ }
+
+ DisposableEffect(Unit) {
+ val gravity = FloatArray(3)
+ val geomagnetic = FloatArray(3)
+ val rotationMatrix = FloatArray(9)
+ val remappedMatrix = FloatArray(9)
+ val orientation = FloatArray(3)
+
+ val listener = object : SensorEventListener {
+ override fun onSensorChanged(event: SensorEvent) {
+ when (event.sensor.type) {
+ Sensor.TYPE_ROTATION_VECTOR -> {
+ SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
+ SensorManager.remapCoordinateSystem(
+ rotationMatrix,
+ SensorManager.AXIS_X,
+ SensorManager.AXIS_Z,
+ remappedMatrix
+ )
+ SensorManager.getOrientation(remappedMatrix, orientation)
+
+ val azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat()
+ degrees = smoothAngle(degrees, (azimuth + 360f) % 360f, smoothingAlpha)
+ }
+
+ Sensor.TYPE_ACCELEROMETER -> {
+ gravity[0] = event.values[0]
+ gravity[1] = event.values[1]
+ gravity[2] = event.values[2]
+ }
+
+ Sensor.TYPE_MAGNETIC_FIELD -> {
+ geomagnetic[0] = event.values[0]
+ geomagnetic[1] = event.values[1]
+ geomagnetic[2] = event.values[2]
+ }
+ }
+
+ if (event.sensor.type != Sensor.TYPE_ROTATION_VECTOR) {
+ val success = SensorManager.getRotationMatrix(
+ rotationMatrix,
+ remappedMatrix,
+ gravity,
+ geomagnetic
+ )
+
+ if (success) {
+ SensorManager.getOrientation(rotationMatrix, orientation)
+ val azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat()
+ degrees = smoothAngle(degrees, (azimuth + 360f) % 360f, smoothingAlpha)
+ }
+ }
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
+ }
+
+ rotationVectorSensor?.let {
+ sensorManager.registerListener(
+ listener, it, SensorManager.SENSOR_DELAY_NORMAL
+ )
+ }
+
+ sensorManager.registerListener(
+ listener, magnetometerSensor, SensorManager.SENSOR_DELAY_NORMAL
+ )
+ sensorManager.registerListener(
+ listener, accelerometerSensor, SensorManager.SENSOR_DELAY_NORMAL
+ )
+
+ onDispose {
+ sensorManager.unregisterListener(listener)
+ }
+ }
+
+ return CompassState(
+ degrees = degrees
+ )
+}
+
+private fun smoothAngle(current: Float, target: Float, alpha: Float): Float {
+ val delta = ((target - current + 540f) % 360f) - 180f
+ return (current + alpha * delta + 360f) % 360f
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/LocationManager.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/LocationManager.kt
new file mode 100644
index 0000000..08e708b
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/LocationManager.kt
@@ -0,0 +1,118 @@
+package com.appnotresponding.rumbo.ui.utils
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Looper
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableDoubleStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.google.accompanist.permissions.shouldShowRationale
+import com.google.android.gms.location.FusedLocationProviderClient
+import com.google.android.gms.location.LocationCallback
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.location.LocationResult
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
+
+class LocationState(
+ val latitude: Double,
+ val longitude: Double,
+ val altitude: Double,
+ val hasPermission: Boolean,
+ val showRationale: Boolean,
+ val requestPermission: () -> Unit
+)
+
+private fun startLocationUpdates(
+ context: android.content.Context,
+ locationClient: FusedLocationProviderClient,
+ locationRequest: LocationRequest,
+ locationCallback: LocationCallback
+) {
+ if (ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.ACCESS_FINE_LOCATION
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ try {
+ locationClient.requestLocationUpdates(
+ locationRequest, locationCallback, Looper.getMainLooper()
+ )
+ } catch (e: SecurityException) {
+ Log.e("LocationManager", "Error al solicitar ubicacion: ${e.message}")
+ }
+ }
+}
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun rememberLocationManager(): LocationState {
+ val context = LocalContext.current
+ val locationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
+
+ var latitude by remember { mutableDoubleStateOf(0.0) }
+ var longitude by remember { mutableDoubleStateOf(0.0) }
+ var altitude by remember { mutableDoubleStateOf(0.0) }
+
+ val locationPermissionState = rememberPermissionState(
+ Manifest.permission.ACCESS_FINE_LOCATION
+ )
+ var showRationaleButton by remember { mutableStateOf(false) }
+
+ SideEffect {
+ if (!locationPermissionState.status.isGranted) {
+ if (locationPermissionState.status.shouldShowRationale) {
+ showRationaleButton = true
+ } else {
+ showRationaleButton = false
+ locationPermissionState.launchPermissionRequest()
+ }
+ }
+ }
+
+ val locationRequest = remember {
+ LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 10000)
+ .setWaitForAccurateLocation(true).setMinUpdateIntervalMillis(5000).build()
+ }
+
+ val locationCallback = remember {
+ object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ super.onLocationResult(result)
+ result.lastLocation?.let {
+ latitude = it.latitude
+ longitude = it.longitude
+ altitude = it.altitude
+ }
+ }
+ }
+ }
+
+ DisposableEffect(locationPermissionState.status.isGranted) {
+ if (locationPermissionState.status.isGranted) {
+ startLocationUpdates(context, locationClient, locationRequest, locationCallback)
+ }
+ onDispose {
+ locationClient.removeLocationUpdates(locationCallback)
+ }
+ }
+
+ return LocationState(
+ latitude = latitude,
+ longitude = longitude,
+ altitude = altitude,
+ hasPermission = locationPermissionState.status.isGranted,
+ showRationale = showRationaleButton,
+ requestPermission = { locationPermissionState.launchPermissionRequest() })
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt
new file mode 100644
index 0000000..ae1172f
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt
@@ -0,0 +1,25 @@
+package com.appnotresponding.rumbo.ui.utils
+
+import com.google.android.gms.location.LocationCallback
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.location.LocationResult
+import com.google.android.gms.location.Priority
+
+fun createLocationRequest() : LocationRequest{
+ val locationRequest = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY, 2000)
+ .setWaitForAccurateLocation(true)
+ .setMinUpdateIntervalMillis(1000).setMinUpdateDistanceMeters(10f)
+ .build()
+ return locationRequest
+}
+
+fun createLocationCallback(onLocationChange: (LocationResult) -> Unit): LocationCallback {
+ val callback = object : LocationCallback() {
+ override fun onLocationResult(locationResult: LocationResult) {
+ super.onLocationResult(locationResult)
+ onLocationChange(locationResult)
+ }
+ }
+ return callback
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MediaHardwareManager.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MediaHardwareManager.kt
new file mode 100644
index 0000000..30ca909
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MediaHardwareManager.kt
@@ -0,0 +1,71 @@
+package com.appnotresponding.rumbo.ui.utils
+
+import android.content.Context
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.FileProvider
+import java.io.File
+
+data class MediaHardwareState(
+ val imageUri: Uri?,
+ val launchCamera: () -> Unit,
+ val launchGallery: () -> Unit,
+ val clearImage: () -> Unit
+)
+
+@Composable
+fun rememberMediaHardwareManager(context: Context = LocalContext.current): MediaHardwareState {
+ var currentImageUri by remember { mutableStateOf(null) }
+ var tempCameraUri by remember { mutableStateOf(null) }
+
+ val galleryLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetContent()
+ ) { uri: Uri? ->
+ if (uri != null) {
+ currentImageUri = uri
+ }
+ }
+
+ val cameraLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.TakePicture()
+ ) { success: Boolean ->
+ if (success) {
+ currentImageUri = tempCameraUri
+ }
+ }
+
+ val launchGallery: () -> Unit = {
+ galleryLauncher.launch("image/*")
+ }
+
+ val launchCamera: () -> Unit = {
+ val uri = createImageUri(context)
+ tempCameraUri = uri
+ cameraLauncher.launch(uri)
+ }
+
+ val clearImage: () -> Unit = {
+ currentImageUri = null
+ }
+
+ return MediaHardwareState(
+ imageUri = currentImageUri,
+ launchCamera = launchCamera,
+ launchGallery = launchGallery,
+ clearImage = clearImage
+ )
+}
+
+private fun createImageUri(context: Context): Uri {
+ val imageFile = File(context.filesDir, "cameraPic.jpg")
+ return FileProvider.getUriForFile(
+ context, "${context.packageName}.fileprovider", imageFile
+ )
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MyApp.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MyApp.kt
new file mode 100644
index 0000000..3253aee
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MyApp.kt
@@ -0,0 +1,41 @@
+package com.appnotresponding.rumbo.ui.utils
+
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.os.Build
+import android.util.Log
+import com.google.firebase.Firebase
+import com.google.firebase.messaging.messaging
+
+class MyApp : Application() {
+ companion object{
+ const val NOTIFICATION_CHANNEL_ID =
+ "notificaion_fcm"
+ var fcmToken: String? = null
+ }
+ override fun onCreate() {
+ super.onCreate()
+ Firebase.messaging.token.addOnSuccessListener {
+ Log.i("FirebaseApp"
+ , "Token: "+it.toString())
+ }
+ createNotificationChannel()
+ Firebase.messaging.token.addOnSuccessListener { token ->
+ fcmToken = token
+ Log.i("FirebaseApp"
+ , "Token: "+token)
+ }
+ }
+ private fun createNotificationChannel(){
+ if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){
+ val channel = NotificationChannel(
+ NOTIFICATION_CHANNEL_ID, "FCM Notifications",
+ NotificationManager.IMPORTANCE_HIGH
+ )
+ channel.description="Channel for FCM Notifications"
+ val notManager = getSystemService(NotificationManager::class.java)
+ notManager.createNotificationChannel(channel)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt
new file mode 100644
index 0000000..5bfdcbd
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt
@@ -0,0 +1,140 @@
+package com.appnotresponding.rumbo.ui.utils
+
+import androidx.compose.animation.core.animateFloatAsState
+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.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.unit.dp
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Navigation
+import androidx.compose.ui.tooling.preview.Preview
+import com.appnotresponding.rumbo.ui.theme.RumboTheme
+
+@Composable
+fun SensorOverlay(modifier: Modifier = Modifier) {
+ val accelerometerState = rememberAccelerometerManager()
+ val compassState = rememberCompassManager()
+
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ horizontalAlignment = Alignment.Start
+ ) {
+ // Indicador de movimiento con el acelerometro
+ CompassWidget(
+ degrees = compassState.degrees,
+ )
+ }
+}
+
+@Composable
+fun CompassWidget(
+ degrees: Float,
+ modifier: Modifier = Modifier
+) {
+
+ val animatedRotation by animateFloatAsState(
+ targetValue = degrees,
+ label = "CompassRotation"
+ )
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(
+ modifier = Modifier
+ .shadow(4.dp, CircleShape)
+ .size(48.dp)
+ .background(
+ MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.9f),
+ CircleShape
+ )
+ .border(
+ 1.dp,
+ MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f),
+ CircleShape
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+
+ // Norte
+ Text(
+ text = "N",
+ modifier = Modifier.offset(y = (-16).dp),
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.labelSmall
+ )
+
+ // Sur
+ Text(
+ text = "S",
+ modifier = Modifier.offset(y = (16).dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Este
+ Text(
+ text = "E",
+ modifier = Modifier.offset(x = (16).dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Oeste
+ Text(
+ text = "O",
+ modifier = Modifier.offset(x = (-16).dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Flecha
+ Icon(
+ imageVector = Icons.Default.Navigation,
+ contentDescription = "Compass Arrow",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .size(16.dp)
+ .rotate(animatedRotation)
+ )
+
+ Box(
+ modifier = Modifier
+ .size(4.dp)
+ .background(
+ MaterialTheme.colorScheme.primary,
+ CircleShape
+ )
+ )
+ }
+
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun CompassWidgetPreview() {
+ RumboTheme() {
+ CompassWidget(
+ degrees = 45f
+ )
+ }
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/firebaseMessagingService.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/firebaseMessagingService.kt
new file mode 100644
index 0000000..430da91
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/firebaseMessagingService.kt
@@ -0,0 +1,29 @@
+package com.appnotresponding.rumbo.ui.utils
+
+import android.util.Log
+import com.google.firebase.messaging.FirebaseMessagingService
+import com.google.firebase.messaging.RemoteMessage
+
+//https://stackoverflow.com/questions/54997485/android-notification-during-app-is-in-background-intent-data-is-empty
+class MyFirebaseMessagingService : FirebaseMessagingService() {
+
+ override fun onMessageReceived(remoteMessage: RemoteMessage) {
+
+ Log.i("FirebaseApp"
+ , "Message Received!!!")
+ val title = remoteMessage.data["title"]
+ val body = remoteMessage.data["body"]
+ val senderId = remoteMessage.data["senderId"]
+ val chatId = remoteMessage.data["chatId"]
+ val senderName = remoteMessage.data["senderName"]
+ val senderPhotoUrl = remoteMessage.data["senderPhotoUrl"]
+ val isOnline = remoteMessage.data["isAvailable"]?.toBoolean()
+ if(title != null && body != null){
+ Log.i("FirebaseApp"
+ , title)
+ Log.i("FirebaseApp"
+ , body)
+ showNotification(title, body, this, senderId, chatId, senderName, senderPhotoUrl, isOnline)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/jsonFunc.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/jsonFunc.kt
new file mode 100644
index 0000000..7ce1204
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/jsonFunc.kt
@@ -0,0 +1,22 @@
+package com.appnotresponding.rumbo.ui.utils
+
+import com.appnotresponding.rumbo.models.User
+import com.google.firebase.database.DataSnapshot
+
+fun DataSnapshot.toUser(uid: String): User {
+ return User(
+ id = uid,
+ name = child("name").getValue(String::class.java) ?: "",
+ lastname = child("lastname").getValue(String::class.java) ?: "",
+ email = child("email").getValue(String::class.java) ?: "",
+ phone = child("phone").getValue(String::class.java) ?: "",
+ latitude = child("latitude").getValue(Double::class.java) ?: 0.0,
+ longitude = child("longitude").getValue(Double::class.java) ?: 0.0,
+ altitude = child("altitude").getValue(Double::class.java) ?: 0.0,
+ profilePictureUrl = child("profilePictureUrl").getValue(String::class.java),
+ sharingLocation = child("sharingLocation").getValue(Boolean::class.java) ?: false,
+ activity = child("activity").getValue(String::class.java),
+ isOnline = child("isOnline").getValue(Boolean::class.java) ?: false,
+ lastSeenAt = child("lastSeenAt").getValue(Long::class.java) ?: 0L
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/notificationUtils.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/notificationUtils.kt
new file mode 100644
index 0000000..e2ac85b
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/notificationUtils.kt
@@ -0,0 +1,65 @@
+package com.appnotresponding.rumbo.ui.utils
+
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat.getSystemService
+import com.appnotresponding.rumbo.MainActivity
+import com.appnotresponding.rumbo.R
+
+fun showNotification(
+ title: String,
+ message: String,
+ context: Context,
+ senderId: String? = null,
+ chatId: String? = null,
+ senderName: String? = null,
+ senderPhotoUrl: String? = null,
+ isOnline: Boolean? = null
+) {
+ val notManager = getSystemService(context, NotificationManager::class.java)
+
+ val intent = Intent(context, MainActivity::class.java).apply {
+ senderId?.let {
+ putExtra("senderId", it)
+ }
+ chatId?.let {
+ putExtra("chatId", it)
+ }
+ senderName?.let {
+ putExtra("senderName", it)
+ }
+ senderPhotoUrl?.let {
+ putExtra("senderPhotoUrl", it)
+ }
+ isOnline?.let {
+ putExtra("isOnline", it)
+ }
+ putExtra("openChat", true)
+
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ val requestCode = chatId?.hashCode() ?: System.currentTimeMillis().toInt()
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ requestCode,
+ intent,
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification = NotificationCompat.Builder(
+ context,
+ MyApp.NOTIFICATION_CHANNEL_ID
+ )
+ .setContentTitle(title)
+ .setContentText(message)
+ .setSmallIcon(R.drawable.brand)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+ .build()
+
+ notManager?.notify(1, notification)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/placesAPI.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/placesAPI.kt
new file mode 100644
index 0000000..74d7e58
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/placesAPI.kt
@@ -0,0 +1,355 @@
+package com.appnotresponding.rumbo.ui.utils
+
+import android.content.Context
+import android.util.Log
+import androidx.compose.foundation.layout.add
+import com.android.volley.Request
+import com.android.volley.Response
+import com.android.volley.toolbox.JsonObjectRequest
+import com.android.volley.toolbox.Volley
+import com.appnotresponding.rumbo.models.Place
+import org.json.JSONArray
+import org.json.JSONObject
+import com.appnotresponding.rumbo.BuildConfig
+
+fun searchNearbyPlaces(
+ latitude: Double,
+ longitude: Double,
+ onPlacesReceived: (List) -> Unit,
+ onError: (String) -> Unit,
+ context: Context,
+ radius: Double = 5000.0
+) {
+ val apiKey = BuildConfig.MAPS_API_KEY
+ val requestQueue = Volley.newRequestQueue(context)
+
+ val url =
+ "https://places.googleapis.com/v1/places:searchNearby"
+
+ val body = JSONObject().apply {
+
+ put(
+ "includedPrimaryTypes",
+ JSONArray().put("tourist_attraction").put("museum").put("historical_landmark").put("cultural_landmark").put("zoo").put("aquarium").put("national_park").put("botanical_garden").put("observation_deck")
+ )
+
+ put(
+ "maxResultCount",
+ 20
+ )
+ put("rankPreference", "POPULARITY")
+ put(
+ "locationRestriction",
+ JSONObject().apply {
+
+ put(
+ "circle",
+ JSONObject().apply {
+
+ put(
+ "center",
+ JSONObject().apply {
+ put("latitude", latitude)
+ put("longitude", longitude)
+ }
+ )
+
+ put("radius", radius)
+ }
+ )
+ }
+ )
+ }
+
+ val request = object : JsonObjectRequest(
+ Request.Method.POST,
+ url,
+ body,
+
+ Response.Listener { response ->
+
+ try {
+ if (!response.has("places")) {
+ onError("La respuesta no contiene lugares")
+ return@Listener
+ }
+ val placesJson = response.getJSONArray("places")
+
+ val placesList = mutableListOf()
+
+ for (i in 0 until placesJson.length()) {
+
+ val item = placesJson.getJSONObject(i)
+
+ val id =
+ item.optString("id", "")
+
+ val name =
+ item.getJSONObject("displayName")
+ .optString("text", "Sin nombre")
+
+ val description =
+ if (item.has("editorialSummary")) {
+
+ item.getJSONObject("editorialSummary")
+ .optString("text", null)
+
+ } else null
+
+ var openHours: List? = null
+
+ if (item.has("currentOpeningHours")) {
+
+ val hours =
+ item.getJSONObject("currentOpeningHours")
+
+ if (hours.has("weekdayDescriptions")) {
+
+ val descriptions =
+ hours.getJSONArray("weekdayDescriptions")
+
+ val list = mutableListOf()
+
+ for (j in 0 until descriptions.length()) {
+
+ list.add(
+ descriptions.getString(j)
+ )
+ }
+
+ openHours = list
+ }
+ }
+
+ val price =
+ if (item.has("priceLevel"))
+ item.getString("priceLevel")
+ else null
+
+ val rating =
+ if (item.has("rating"))
+ item.getDouble("rating")
+ else null
+
+ val location =
+ item.getJSONObject("location")
+
+ val latitude =
+ location.getDouble("latitude")
+
+ val longitude =
+ location.getDouble("longitude")
+ val address =
+ item.optString(
+ "formattedAddress",
+ "Sin dirección"
+ )
+
+ var imageUrl: String? = null
+
+ if (item.has("photos")) {
+
+ val photos =
+ item.getJSONArray("photos")
+
+ if (photos.length() > 0) {
+
+ val photo =
+ photos.getJSONObject(0)
+
+ val photoName =
+ photo.optString("name")
+
+ imageUrl =
+ "https://places.googleapis.com/v1/$photoName/media" +
+ "?maxHeightPx=400" +
+ "&maxWidthPx=400" +
+ "&key=$apiKey"
+ }
+ }
+
+ placesList.add(
+
+ Place(
+ id = id,
+ name = name,
+ address = address,
+ description = description,
+ openHours = openHours,
+ price = price,
+ latitude = latitude,
+ longitude = longitude,
+ rating = rating,
+ reviews = emptyList(),
+ imageUrl = imageUrl
+ )
+ )
+ }
+
+ onPlacesReceived(placesList)
+
+ } catch (e: Exception) {
+
+ onError(
+ "Error parseando respuesta: ${e.message}"
+ )
+ }
+ },
+
+ Response.ErrorListener { error ->
+
+ Log.e("VOLLEY_ERROR", error.toString())
+
+ if (error.networkResponse != null) {
+
+ val code = error.networkResponse.statusCode
+
+ val data = String(error.networkResponse.data)
+
+ onError(
+ "Error $code\n$data"
+ )
+
+ } else {
+
+ onError(
+ error.message ?: "Error desconocido"
+ )
+ }
+ }
+
+ ) {
+
+ override fun getHeaders(): MutableMap {
+
+ return hashMapOf(
+ "Content-Type" to "application/json",
+ "X-Goog-Api-Key" to apiKey,
+ "Accept-Language" to "es",
+
+ // MUY IMPORTANTE
+ "X-Goog-FieldMask" to
+ "places.id," +
+ "places.displayName," +
+ "places.formattedAddress," +
+ "places.editorialSummary," +
+ "places.currentOpeningHours," +
+ "places.priceLevel," +
+ "places.location," +
+ "places.rating," +
+ "places.photos"
+ )
+ }
+ }
+
+ requestQueue.add(request)
+}
+
+fun searchTextPlaces(
+ query: String,
+ onPlacesReceived: (List) -> Unit,
+ onError: (String) -> Unit,
+ context: Context
+) {
+ val apiKey = BuildConfig.MAPS_API_KEY
+ val requestQueue = Volley.newRequestQueue(context)
+ val url = "https://places.googleapis.com/v1/places:searchText"
+
+ val body = JSONObject().apply {
+ put("textQuery", query)
+ put("maxResultCount", 20)
+ }
+
+ val request = object : JsonObjectRequest(
+ Request.Method.POST,
+ url,
+ body,
+ Response.Listener { response ->
+ try {
+ if (!response.has("places")) {
+ onPlacesReceived(emptyList())
+ return@Listener
+ }
+ val placesJson = response.getJSONArray("places")
+ val placesList = mutableListOf()
+ for (i in 0 until placesJson.length()) {
+ val item = placesJson.getJSONObject(i)
+ val id = item.optString("id", "")
+ val name = item.getJSONObject("displayName").optString("text", "Sin nombre")
+ val description = if (item.has("editorialSummary")) {
+ item.getJSONObject("editorialSummary").optString("text", null)
+ } else null
+
+ var openHours: List? = null
+ if (item.has("currentOpeningHours")) {
+ val hours = item.getJSONObject("currentOpeningHours")
+ if (hours.has("weekdayDescriptions")) {
+ val descriptions = hours.getJSONArray("weekdayDescriptions")
+ val list = mutableListOf()
+ for (j in 0 until descriptions.length()) {
+ list.add(descriptions.getString(j))
+ }
+ openHours = list
+ }
+ }
+
+ val price = if (item.has("priceLevel")) item.getString("priceLevel") else null
+ val rating = if (item.has("rating")) item.getDouble("rating") else null
+ val location = item.getJSONObject("location")
+ val latitude = location.getDouble("latitude")
+ val longitude = location.getDouble("longitude")
+ val address = item.optString("formattedAddress", "Sin dirección")
+
+ var imageUrl: String? = null
+ if (item.has("photos")) {
+ val photos = item.getJSONArray("photos")
+ if (photos.length() > 0) {
+ val photo = photos.getJSONObject(0)
+ val photoName = photo.optString("name")
+ imageUrl = "https://places.googleapis.com/v1/$photoName/media?maxHeightPx=400&maxWidthPx=400&key=$apiKey"
+ }
+ }
+
+ placesList.add(
+ Place(
+ id = id,
+ name = name,
+ address = address,
+ description = description,
+ openHours = openHours,
+ price = price,
+ latitude = latitude,
+ longitude = longitude,
+ rating = rating,
+ reviews = emptyList(),
+ imageUrl = imageUrl
+ )
+ )
+ }
+ onPlacesReceived(placesList)
+ } catch (e: Exception) {
+ onError("Error parsing text search response: ${e.message}")
+ }
+ },
+ Response.ErrorListener { error ->
+ Log.e("VOLLEY_TEXT_SEARCH_ERROR", error.toString())
+ if (error.networkResponse != null) {
+ val code = error.networkResponse.statusCode
+ val data = String(error.networkResponse.data)
+ onError("Error $code\n$data")
+ } else {
+ onError(error.message ?: "Unknown error")
+ }
+ }
+ ) {
+ override fun getHeaders(): MutableMap {
+ return hashMapOf(
+ "Content-Type" to "application/json",
+ "X-Goog-Api-Key" to apiKey,
+ "Accept-Language" to "es",
+ "X-Goog-FieldMask" to "places.id,places.displayName,places.formattedAddress,places.editorialSummary,places.currentOpeningHours,places.priceLevel,places.location,places.rating,places.photos"
+ )
+ }
+ }
+ requestQueue.add(request)
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/ItineraryHistoryViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/ItineraryHistoryViewModel.kt
new file mode 100644
index 0000000..fc1bcf9
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/ItineraryHistoryViewModel.kt
@@ -0,0 +1,80 @@
+package com.appnotresponding.rumbo.ui.viewModel
+
+import android.content.Context
+import android.location.Geocoder
+import android.location.Location
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import com.appnotresponding.rumbo.models.Place
+import com.appnotresponding.rumbo.models.VisitedPlace
+import com.google.firebase.database.FirebaseDatabase
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+private const val TAG = "ItineraryHistoryVM"
+const val VISIT_RADIUS_METERS = 100f
+
+class ItineraryHistoryViewModel : ViewModel() {
+ private val dbHistory = FirebaseDatabase.getInstance().getReference("itineraryHistory")
+ private val dayFormatter = DateTimeFormatter.ISO_LOCAL_DATE
+
+ fun markPlaceVisitedIfNeeded(
+ userId: String, place: Place?, userLat: Double, userLng: Double, context: Context
+ ) {
+ if (userId.isBlank() || place == null) return
+ if (userLat == 0.0 && userLng == 0.0) return
+
+ val distance = FloatArray(1)
+ Location.distanceBetween(
+ userLat, userLng, place.latitude, place.longitude, distance
+ )
+ if (distance.first() > VISIT_RADIUS_METERS) return
+
+ val visitedAt = System.currentTimeMillis()
+ val city = resolveCity(context, place)
+ val cityKey = toFirebaseKey(city)
+ val dayKey = Instant.ofEpochMilli(visitedAt).atZone(ZoneId.systemDefault()).toLocalDate()
+ .format(dayFormatter)
+ val placeKey = toFirebaseKey(place.id)
+
+ val visitedPlace = VisitedPlace(
+ placeId = place.id,
+ placeName = place.name,
+ address = place.address,
+ latitude = place.latitude,
+ longitude = place.longitude,
+ imageUrl = place.imageUrl,
+ city = city,
+ visitedAt = visitedAt
+ )
+
+ dbHistory.child(userId).child(cityKey).child(dayKey).child(placeKey).setValue(visitedPlace)
+ .addOnFailureListener { error ->
+ Log.e(TAG, "Error guardando visita: ${error.message}", error)
+ }
+ }
+
+ private fun resolveCity(context: Context, place: Place): String {
+ val cityFromGeocoder = try {
+ val geocoder = Geocoder(context, Locale.getDefault())
+ @Suppress("DEPRECATION") geocoder.getFromLocation(place.latitude, place.longitude, 1)
+ ?.firstOrNull()?.let { address ->
+ address.locality ?: address.subAdminArea ?: address.adminArea
+ }
+ } catch (error: Exception) {
+ Log.w(TAG, "No se pudo resolver ciudad: ${error.message}")
+ null
+ }
+
+ return cityFromGeocoder?.takeIf { it.isNotBlank() } ?: place.address.split(",")
+ .map { it.trim() }.firstOrNull { it.isNotBlank() } ?: "Sin ciudad"
+ }
+
+ private fun toFirebaseKey(raw: String): String {
+ val cleaned = raw.ifBlank { "sin-id" }.replace(".", "_").replace("#", "_").replace("$", "_")
+ .replace("[", "_").replace("]", "_").replace("/", "_")
+ return cleaned.ifBlank { "sin-id" }
+ }
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/ProfileViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/ProfileViewModel.kt
new file mode 100644
index 0000000..ff334d9
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/ProfileViewModel.kt
@@ -0,0 +1,187 @@
+package com.appnotresponding.rumbo.ui.viewModel
+
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import com.appnotresponding.rumbo.models.DropNote
+import com.appnotresponding.rumbo.models.User
+import com.appnotresponding.rumbo.models.VisitedPlace
+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 com.google.firebase.storage.FirebaseStorage
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+data class ProfileState(
+ val isSavingProfile: Boolean = false,
+ val profileError: String = "",
+ val profileSuccess: String = "",
+ val itineraryHistory: Map> = emptyMap(),
+ val userDropNotes: List = emptyList()
+)
+
+class ProfileViewModel : ViewModel() {
+ private val auth = FirebaseAuth.getInstance()
+ private val dbUsers = FirebaseDatabase.getInstance().getReference("users")
+ private val dbDropNotes = FirebaseDatabase.getInstance().getReference("dropNotes")
+ private val dbItineraryHistory = FirebaseDatabase.getInstance().getReference("itineraryHistory")
+
+ private val _uiState = MutableStateFlow(ProfileState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var dropNotesListener: ValueEventListener? = null
+ private var historyListener: ValueEventListener? = null
+ private var loadedDropNotesUid: String? = null
+ private var loadedHistoryUid: String? = null
+
+ fun updateProfile(
+ user: User,
+ name: String,
+ lastname: String,
+ phone: String,
+ photoUri: Uri?,
+ onSuccess: () -> Unit = {}
+ ) {
+ val uid = auth.currentUser?.uid ?: user.id
+ if (uid.isBlank()) {
+ _uiState.update { it.copy(profileError = "No hay usuario autenticado") }
+ return
+ }
+
+ _uiState.update {
+ it.copy(isSavingProfile = true, profileError = "", profileSuccess = "")
+ }
+
+ fun saveFields(photoUrl: String?) {
+ val updates = mutableMapOf(
+ "name" to name.trim(), "lastname" to lastname.trim(), "phone" to phone.trim()
+ )
+ if (photoUrl != null) {
+ updates["profilePictureUrl"] = photoUrl
+ }
+
+ dbUsers.child(uid).updateChildren(updates).addOnSuccessListener {
+ _uiState.update {
+ it.copy(
+ isSavingProfile = false,
+ profileError = "",
+ profileSuccess = "Perfil actualizado"
+ )
+ }
+ onSuccess()
+ }.addOnFailureListener { error ->
+ _uiState.update {
+ it.copy(
+ isSavingProfile = false,
+ profileError = error.message ?: "Error al actualizar el perfil"
+ )
+ }
+ }
+ }
+
+ if (photoUri != null) {
+ val storageRef = FirebaseStorage.getInstance("gs://rumbowapp.firebasestorage.app")
+ .getReference("profile_pictures/$uid.jpg")
+ storageRef.putFile(photoUri).addOnSuccessListener {
+ storageRef.downloadUrl.addOnSuccessListener { url -> saveFields(url.toString()) }
+ .addOnFailureListener { error ->
+ _uiState.update {
+ it.copy(
+ isSavingProfile = false,
+ profileError = error.message ?: "Error al obtener la foto"
+ )
+ }
+ }
+ }.addOnFailureListener { error ->
+ _uiState.update {
+ it.copy(
+ isSavingProfile = false,
+ profileError = error.message ?: "Error al subir la foto"
+ )
+ }
+ }
+ } else {
+ saveFields(null)
+ }
+ }
+
+ fun loadItineraryHistory(uid: String) {
+ if (uid.isBlank() || loadedHistoryUid == uid) return
+ historyListener?.let { oldListener ->
+ loadedHistoryUid?.let { oldUid ->
+ dbItineraryHistory.child(oldUid).removeEventListener(oldListener)
+ }
+ }
+ loadedHistoryUid = uid
+ val listener = object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val dayMap = mutableMapOf>()
+ snapshot.children.forEach { citySnapshot ->
+ citySnapshot.children.forEach { daySnapshot ->
+ val dayKey = daySnapshot.key ?: "Sin fecha"
+ val places = daySnapshot.children.mapNotNull {
+ it.getValue(VisitedPlace::class.java)
+ }
+ if (places.isNotEmpty()) {
+ val list = dayMap.getOrPut(dayKey) { mutableListOf() }
+ list.addAll(places)
+ }
+ }
+ }
+
+ val sortedGrouped = dayMap.mapValues { entry ->
+ entry.value.sortedByDescending { it.visitedAt }
+ }
+
+ _uiState.update { it.copy(itineraryHistory = sortedGrouped) }
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ _uiState.update {
+ it.copy(profileError = error.message)
+ }
+ }
+ }
+ historyListener = listener
+ dbItineraryHistory.child(uid).addValueEventListener(listener)
+ }
+
+ fun loadUserDropNotes(uid: String) {
+ if (uid.isBlank() || loadedDropNotesUid == uid) return
+ dropNotesListener?.let { oldListener ->
+ dbDropNotes.removeEventListener(oldListener)
+ }
+ loadedDropNotesUid = uid
+ val listener = object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val notes = snapshot.children.mapNotNull {
+ it.getValue(DropNote::class.java)
+ }.filter { it.creatorId == uid }.sortedByDescending { it.timestamp }
+
+ _uiState.update { it.copy(userDropNotes = notes) }
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ _uiState.update {
+ it.copy(profileError = error.message)
+ }
+ }
+ }
+ dropNotesListener = listener
+ dbDropNotes.addValueEventListener(listener)
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ dropNotesListener?.let { dbDropNotes.removeEventListener(it) }
+ historyListener?.let { listener ->
+ loadedHistoryUid?.let { uid ->
+ dbItineraryHistory.child(uid).removeEventListener(listener)
+ }
+ }
+ }
+}
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..e1ec84d
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt
@@ -0,0 +1,466 @@
+package com.appnotresponding.rumbo.ui.viewModel
+
+import androidx.lifecycle.ViewModel
+import com.appnotresponding.rumbo.models.ChatMessage
+import com.appnotresponding.rumbo.models.User
+import com.appnotresponding.rumbo.ui.utils.toUser
+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,
+ val otherUserIsOnline: Boolean = false
+)
+
+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 var onlineListener: ValueEventListener? = null
+ private var onlineRef: com.google.firebase.database.DatabaseReference? = null
+
+ private val dbUsers = db.getReference("users")
+ private val userCache = mutableMapOf()
+ private val userListeners = mutableMapOf()
+
+ private var currentMessages: List = emptyList()
+
+ private fun clearUserListeners() {
+ userListeners.forEach { (senderId, listener) ->
+ dbUsers.child(senderId).removeEventListener(listener)
+ }
+ userListeners.clear()
+ userCache.clear()
+
+ currentMessages = emptyList()
+ }
+
+ private fun listenToOtherUserOnline(otherUid: String) {
+ // Limpia listener previo si existe
+ onlineListener?.let { onlineRef?.removeEventListener(it) }
+ val ref = dbUsers.child(otherUid).child("isOnline")
+ onlineRef = ref
+ val listener = object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val isOnline = snapshot.getValue(Boolean::class.java) ?: false
+ _uiState.update { it.copy(otherUserIsOnline = isOnline) }
+ // Actualiza también el cache para que messageAuthors refleje el estado
+ userCache[otherUid]?.let { cached ->
+ userCache[otherUid] = cached.copy(isOnline = isOnline)
+ _uiState.update { it.copy(messageAuthors = userCache.toMap()) }
+ }
+ }
+ override fun onCancelled(error: DatabaseError) {}
+ }
+ onlineListener = listener
+ ref.addValueEventListener(listener)
+ }
+
+
+ /**
+ 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.toUser(senderId)
+ userCache[senderId] = user
+ pushState()
+ }
+
+ override fun onCancelled(error: DatabaseError) {}
+ }
+ userListeners[senderId] = listener
+ dbUsers.child(senderId).addValueEventListener(listener)
+ }
+ }
+
+ pushState()
+ } */
+ private fun resolveUsersAndEmit(
+ rawMessages: List,
+ extraUid: String? = null
+ ) {
+ currentMessages = rawMessages
+
+ val uniqueSenderIds =
+ (rawMessages.map { it.senderId } + listOfNotNull(extraUid))
+ .distinct()
+
+ uniqueSenderIds.forEach { senderId ->
+
+ if (userCache.containsKey(senderId) || userListeners.containsKey(senderId)) {
+ return@forEach
+ }
+
+ val listener = object : ValueEventListener {
+
+ override fun onDataChange(userSnapshot: DataSnapshot) {
+ val user = userSnapshot.toUser(senderId)
+
+ userCache[senderId] = user
+
+ emitCurrentState()
+ }
+
+ override fun onCancelled(error: DatabaseError) {}
+ }
+
+ userListeners[senderId] = listener
+
+ dbUsers
+ .child(senderId)
+ .addValueEventListener(listener)
+ }
+
+ emitCurrentState()
+ }
+
+ private fun emitCurrentState() {
+
+ _uiState.update {
+ it.copy(
+ messages = currentMessages,
+ messageAuthors = userCache.toMap()
+ )
+ }
+ }
+
+ fun listenToMessages(chatId: String) {
+ clearUserListeners()
+ currentMessages = emptyList()
+
+ _uiState.update {
+ it.copy(
+ messages = emptyList(),
+ messageAuthors = emptyMap()
+ )
+ }
+ currentRef?.let { ref ->
+ currentListener?.let { ref.removeEventListener(it) }
+ }
+ currentMetaRef?.let { ref ->
+ currentMetaListener?.let { ref.removeEventListener(it) }
+ }
+ _uiState.update { it.copy(otherUserIsOnline = false) }
+
+ val myUid = auth.currentUser?.uid ?: ""
+
+ // Escucha isOnline del otro usuario en tiempo real
+ val parts = chatId.split("_")
+ val otherUid = parts.firstOrNull { it != myUid }
+ if (otherUid != null) {
+ listenToOtherUserOnline(otherUid)
+ }
+ 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) }
+ }
+ // En grupos no hay estado online individual
+ onlineListener?.let { onlineRef?.removeEventListener(it) }
+ onlineListener = null
+ onlineRef = null
+ _uiState.update { it.copy(otherUserIsOnline = false) }
+
+ 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) }
+ }
+ onlineListener?.let { onlineRef?.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..bdf9655
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt
@@ -0,0 +1,304 @@
+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.appnotresponding.rumbo.ui.utils.toUser
+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
+
+ private val friendUids = mutableSetOf()
+ private var friendshipListener: ValueEventListener? = 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.toUser(otherUid)
+ 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
+ }
+
+ for (child in children) {
+ val chatId = child.key ?: continue
+ val participants = child.child("participants").children.map { it.value as? String ?: "" }
+ if (!participants.contains(myUid)) continue
+ val otherUid = participants.firstOrNull { it != myUid } ?: continue
+
+ val areFriends = friendUids.contains(otherUid)
+ if (!areFriends) continue
+
+ setupUserListener(otherUid, myUid)
+
+ 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)
+
+ 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
+ )
+ )
+ }
+ }
+ _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) }
+ }
+
+ private fun listenToFriendships(myUid: String) {
+ friendshipListener?.let { db.getReference("friendships").child(myUid).removeEventListener(it) }
+ val listener = object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val uids = snapshot.children.mapNotNull { it.key }.toSet()
+ friendUids.clear()
+ friendUids.addAll(uids)
+ rebuildConversationsList(myUid)
+ }
+ override fun onCancelled(error: DatabaseError) {}
+ }
+ friendshipListener = listener
+ db.getReference("friendships").child(myUid).addValueEventListener(listener)
+ }
+
+ private fun listenToDirectChats(myUid: String) {
+ listenToFriendships(myUid)
+ val listener = object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ latestChatsSnapshot = snapshot
+
+ snapshot.children.forEach { child ->
+ val participants = child.child("participants").children.map { it.value as? String ?: "" }
+ val otherUid = participants.firstOrNull { it != myUid }
+ if (otherUid != null) {
+ setupUserListener(otherUid, myUid)
+ }
+ }
+
+ 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,
+ placePhotoUrl = place.imageUrl,
+ 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, photoUrl: String? = null) {
+ _uiState.update {
+ it.copy(
+ selectedChatId = placeId,
+ selectedChatTitle = placeName,
+ selectedChatPhoto = photoUrl,
+ 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() {
+ val myUid = auth.currentUser?.uid
+ if (myUid != null) {
+ friendshipListener?.let { db.getReference("friendships").child(myUid).removeEventListener(it) }
+ }
+ friendshipListener = null
+ friendUids.clear()
+
+ 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/dropNoteViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/dropNoteViewModel.kt
new file mode 100644
index 0000000..6260474
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/dropNoteViewModel.kt
@@ -0,0 +1,240 @@
+package com.appnotresponding.rumbo.ui.viewModel
+
+import android.net.Uri
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import com.appnotresponding.rumbo.models.DropNote
+import com.appnotresponding.rumbo.models.User
+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 com.google.firebase.storage.FirebaseStorage
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+data class DropNoteState(
+ val dropNotes: List = emptyList(),
+ val dropNoteAuthors: Map = emptyMap(),
+ val isUploadingNote: Boolean = false,
+ val noteUploadError: String = ""
+)
+
+private const val TAG = "DropNoteVM"
+private const val EXPIRATION_MS = 12 * 60 * 60 * 1000L // 12 horas
+
+class DropNoteViewModel : ViewModel() {
+
+ private val _uiState = MutableStateFlow(DropNoteState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val dbDropNotes = FirebaseDatabase.getInstance().getReference("dropNotes")
+ private val dbUsers = FirebaseDatabase.getInstance().getReference("users")
+
+ /** Cache en memoria para usuarios ya resueltos. */
+ private val userCache = mutableMapOf()
+
+ /** Listeners activos de usuarios (para hacer removeEventListener en onCleared). */
+ private val userListeners = mutableMapOf()
+
+ init {
+ fetchDropNotes()
+ }
+
+ private fun fetchDropNotes() {
+ dbDropNotes.addValueEventListener(object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val notes = mutableListOf()
+ val currentTime = System.currentTimeMillis()
+
+ Log.d(TAG, "fetchDropNotes: total children = ${snapshot.childrenCount}")
+
+ for (child in snapshot.children) {
+ val note = child.getValue(DropNote::class.java) ?: continue
+ val isExpired = (currentTime - note.timestamp) > EXPIRATION_MS
+
+ if (note.public && !isExpired) {
+ notes.add(note)
+ Log.d(TAG, "Nota activa: id=${note.id} creatorId=${note.creatorId}")
+ } else if (note.public && isExpired) {
+ // TODO: migrar a Cloud Function para no hacer escrituras desde el cliente
+ dbDropNotes.child(note.id).child("public").setValue(false)
+ Log.d(TAG, "Nota expirada y desactivada: id=${note.id}")
+ }
+ }
+
+ Log.d(TAG, "Notas activas encontradas: ${notes.size}")
+ resolveUsersAndEmit(notes)
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ Log.e(TAG, "fetchDropNotes cancelado: ${error.message}")
+ }
+ })
+ }
+
+ private fun resolveUsersAndEmit(rawNotes: List) {
+ val uniqueCreatorIds = rawNotes.map { it.creatorId }.distinct()
+ Log.d(TAG, "resolveUsers: creatorIds únicos = $uniqueCreatorIds")
+
+ fun pushState() {
+ val authorsMap = userCache.toMap()
+ Log.d(TAG, "pushState: usuarios en caché = ${authorsMap.keys}")
+ _uiState.update {
+ it.copy(dropNotes = rawNotes, dropNoteAuthors = authorsMap)
+ }
+ }
+
+ uniqueCreatorIds.forEach { creatorId ->
+ if (!userCache.containsKey(creatorId) && !userListeners.containsKey(creatorId)) {
+ Log.d(TAG, "Cargando usuario desde Firebase: $creatorId")
+ val listener = object : ValueEventListener {
+ override fun onDataChange(userSnapshot: DataSnapshot) {
+ Log.d(TAG, "Usuario raw snapshot[$creatorId]: ${userSnapshot.value}")
+ val user = userSnapshot.getValue(User::class.java)
+ Log.d(TAG, "Usuario parseado[$creatorId]: $user")
+ if (user != null) {
+ val isOnlineVal = userSnapshot.child("isOnline").getValue(Boolean::class.java) ?: false
+ userCache[creatorId] = user.copy(isOnline = isOnlineVal)
+ Log.d(TAG, "Usuario cacheado[$creatorId]: avatarUrl=${user.profilePictureUrl}")
+ } else {
+ Log.w(TAG, "Usuario nulo para creatorId=$creatorId — verifica users/$creatorId en Firebase")
+ }
+ pushState()
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ Log.e(TAG, "Error al cargar usuario[$creatorId]: ${error.message}")
+ }
+ }
+ userListeners[creatorId] = listener
+ dbUsers.child(creatorId).addValueEventListener(listener)
+ } else {
+ Log.d(TAG, "Usuario ya en caché o listener activo: $creatorId")
+ }
+ }
+
+ // Emitir estado inmediato con lo que ya hay en caché
+ pushState()
+ }
+
+ fun uploadAndSaveDropNote(
+ content: String,
+ imageUri: Uri?,
+ latitude: Double,
+ longitude: Double,
+ creatorId: String,
+ onSuccess: () -> Unit
+ ) {
+ val noteId = dbDropNotes.push().key ?: java.util.UUID.randomUUID().toString()
+ _uiState.update { it.copy(isUploadingNote = true, noteUploadError = "") }
+
+ if (imageUri != null) {
+ val storageRef = FirebaseStorage.getInstance("gs://rumbowapp.firebasestorage.app")
+ .getReference("drop_notes/$noteId.jpg")
+ storageRef.putFile(imageUri)
+ .addOnSuccessListener {
+ storageRef.downloadUrl.addOnSuccessListener { downloadUrl ->
+ saveDropNoteMetadata(
+ noteId = noteId,
+ content = content,
+ imageUrl = downloadUrl.toString(),
+ latitude = latitude,
+ longitude = longitude,
+ creatorId = creatorId,
+ onSuccess = onSuccess
+ )
+ }.addOnFailureListener { e ->
+ _uiState.update {
+ it.copy(isUploadingNote = false, noteUploadError = e.message ?: "Error al obtener URL")
+ }
+ }
+ }
+ .addOnFailureListener { e ->
+ _uiState.update {
+ it.copy(isUploadingNote = false, noteUploadError = e.message ?: "Error al subir imagen")
+ }
+ }
+ } else {
+ saveDropNoteMetadata(
+ noteId = noteId,
+ content = content,
+ imageUrl = null,
+ latitude = latitude,
+ longitude = longitude,
+ creatorId = creatorId,
+ onSuccess = onSuccess
+ )
+ }
+ }
+
+ private fun saveDropNoteMetadata(
+ noteId: String,
+ content: String,
+ imageUrl: String?,
+ latitude: Double,
+ longitude: Double,
+ creatorId: String,
+ onSuccess: () -> Unit
+ ) {
+ val dropNote = DropNote(
+ id = noteId,
+ creatorId = creatorId,
+ content = content,
+ imageUrl = imageUrl,
+ timestamp = System.currentTimeMillis(),
+ latitude = latitude,
+ longitude = longitude,
+ public = true
+ )
+ dbDropNotes.child(noteId).setValue(dropNote)
+ .addOnSuccessListener {
+ _uiState.update { it.copy(isUploadingNote = false) }
+ onSuccess()
+ }
+ .addOnFailureListener { e ->
+ _uiState.update {
+ it.copy(
+ isUploadingNote = false,
+ noteUploadError = e.message ?: "Error al guardar en base de datos"
+ )
+ }
+ }
+ }
+
+ fun deleteDropNote(
+ noteId: String,
+ imageUrl: String?,
+ onSuccess: () -> Unit,
+ onFailure: (String) -> Unit
+ ) {
+ dbDropNotes.child(noteId).removeValue()
+ .addOnSuccessListener {
+ if (!imageUrl.isNullOrEmpty()) {
+ val storageRef = FirebaseStorage.getInstance("gs://rumbowapp.firebasestorage.app")
+ .getReference("drop_notes/$noteId.jpg")
+ storageRef.delete()
+ .addOnSuccessListener { onSuccess() }
+ .addOnFailureListener { e ->
+ Log.d(TAG, "No se pudo eliminar imagen: ${e.message}")
+ onSuccess() // nota ya borrada de DB, imagen opcional
+ }
+ } else {
+ onSuccess()
+ }
+ }
+ .addOnFailureListener { e ->
+ onFailure(e.message ?: "Error al eliminar DropNote")
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ userListeners.forEach { (creatorId, listener) ->
+ dbUsers.child(creatorId).removeEventListener(listener)
+ }
+ userListeners.clear()
+ }
+}
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..af2e5a2
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt
@@ -0,0 +1,289 @@
+package com.appnotresponding.rumbo.ui.viewModel
+
+import androidx.lifecycle.ViewModel
+import com.appnotresponding.rumbo.models.User
+import com.appnotresponding.rumbo.ui.utils.toUser
+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.toUser(friendId)
+ 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.toUser(senderId)
+ 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.toUser(child.key ?: "")
+ 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.toUser(child.key ?: "")
+ 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/mapViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt
new file mode 100644
index 0000000..6797f4f
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt
@@ -0,0 +1,122 @@
+package com.appnotresponding.rumbo.ui.viewModel
+
+import androidx.lifecycle.ViewModel
+import com.google.android.gms.maps.model.LatLng
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+data class MyMarker(var position: LatLng,
+ var title: String = "Marker", var snippet: String ="Desc")
+
+data class HeatmapCluster(val position: LatLng, val count: Int)
+data class MapState(
+ val userMarker: MyMarker = MyMarker(LatLng(0.0, 0.0)),
+ val additionalMarker: MyMarker = MyMarker(LatLng(0.0, 0.0)),
+ val additionalMarkerVisible: Boolean = false,
+ val routePoints: List = emptyList(),
+ val userRoutePoints: List = emptyList(),
+ val userRouteVisible: Boolean = false,
+ val place: String = "",
+ val centerInUserFirstTime: Boolean = true,
+ val lastSafeLatLng: LatLng = LatLng(0.0, 0.0),
+ val isHeatmapVisible: Boolean = false,
+ val heatmapClusters: List = emptyList(),
+ val heatmapPoints: List = emptyList()
+)
+
+class MapViewModel : ViewModel() {
+
+ private val _uiState = MutableStateFlow(MapState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ fetchHeatmapPoints()
+ }
+
+ private fun fetchHeatmapPoints() {
+ com.google.firebase.database.FirebaseDatabase.getInstance().getReference("users")
+ .addValueEventListener(object : com.google.firebase.database.ValueEventListener {
+ override fun onDataChange(snapshot: com.google.firebase.database.DataSnapshot) {
+ val points = mutableListOf()
+ for (userSnapshot in snapshot.children) {
+ val lat = userSnapshot.child("latitude").value?.toString()?.toDoubleOrNull()
+ val lng = userSnapshot.child("longitude").value?.toString()?.toDoubleOrNull()
+ if (lat != null && lng != null && (lat != 0.0 || lng != 0.0)) {
+ points.add(LatLng(lat, lng))
+ }
+ }
+
+ // Clustering algorithm (200 meters radius)
+ val clusters = mutableListOf()
+ for (p in points) {
+ var added = false
+ for (i in clusters.indices) {
+ val c = clusters[i]
+ val distance = com.google.maps.android.SphericalUtil.computeDistanceBetween(p, c.position)
+ if (distance <= 200.0) {
+ // Merge into this cluster
+ val newLat = (c.position.latitude * c.count + p.latitude) / (c.count + 1)
+ val newLng = (c.position.longitude * c.count + p.longitude) / (c.count + 1)
+ clusters[i] = HeatmapCluster(LatLng(newLat, newLng), c.count + 1)
+ added = true
+ break
+ }
+ }
+ if (!added) {
+ clusters.add(HeatmapCluster(p, 1))
+ }
+ }
+
+ // Allow isolated users (count >= 1)
+ _uiState.update { it.copy(heatmapClusters = clusters, heatmapPoints = points) }
+ }
+
+ override fun onCancelled(error: com.google.firebase.database.DatabaseError) {
+ android.util.Log.e("MapViewModel", "Error fetching users for heatmap: ${error.message}")
+ }
+ })
+ }
+
+ fun toggleHeatmap() {
+ _uiState.update { it.copy(isHeatmapVisible = !it.isHeatmapVisible) }
+ }
+
+ fun updatePlace(place: String) {
+ _uiState.update { it.copy(place = place) }
+ }
+
+ fun updateCenterInUserFirstTime() {
+ _uiState.update { it.copy(centerInUserFirstTime = false) }
+ }
+
+ fun updateUserMarker(lat: Double, lng: Double) {
+ _uiState.update { it.copy(userMarker = MyMarker(LatLng(lat, lng))) }
+ }
+
+ fun updateAdditionalMarker(position: LatLng, title: String) { _uiState.update { it.copy(additionalMarker = MyMarker(position), additionalMarkerVisible = true
+ )
+ }
+ }
+
+ fun cancelAdditionalMarkerVisibility() {
+ _uiState.update { it.copy(additionalMarkerVisible = false) }
+ }
+
+ fun updateRoutePoints(points: List) {
+ _uiState.update { it.copy(routePoints = points) }
+ }
+
+ fun updateUserRouteVisible() {
+ _uiState.update { it.copy(userRouteVisible = !it.userRouteVisible) }
+ }
+
+ fun updateLastSafeLatLng(lat: Double, lng: Double) {
+ _uiState.update { it.copy(lastSafeLatLng = LatLng(lat, lng)) }
+ }
+
+ fun updateUserRoutePoints(points: List) {
+ _uiState.update { it.copy(userRoutePoints = points) }
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..1b5c70d
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt
@@ -0,0 +1,279 @@
+package com.appnotresponding.rumbo.ui.viewModel
+
+import androidx.lifecycle.ViewModel
+import com.appnotresponding.rumbo.models.Place
+import com.google.android.gms.maps.model.LatLng
+import com.appnotresponding.rumbo.models.PlaceState
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.database.FirebaseDatabase
+import com.google.firebase.database.DatabaseReference
+import com.google.firebase.database.ValueEventListener
+import com.google.firebase.database.DataSnapshot
+import com.google.firebase.database.DatabaseError
+
+class PlacesViewModel : ViewModel() {
+ private val _uiState = MutableStateFlow(PlaceState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var rawNearbyPlaces: List = emptyList()
+ private var rawSearchPlaces: List = emptyList()
+ private var firebaseReviews: Map> = emptyMap()
+
+ private val auth = FirebaseAuth.getInstance()
+ private var itineraryDbRef: DatabaseReference? = null
+ private var itineraryListener: ValueEventListener? = null
+
+ init {
+ observeReviews()
+ observeItineraryAndSelectedPlace()
+ }
+
+ private fun observeReviews() {
+ com.google.firebase.database.FirebaseDatabase.getInstance().getReference("places_reviews")
+ .addValueEventListener(object : com.google.firebase.database.ValueEventListener {
+ override fun onDataChange(snapshot: com.google.firebase.database.DataSnapshot) {
+ val map = mutableMapOf>()
+ for (placeSnapshot in snapshot.children) {
+ val placeId = placeSnapshot.key ?: continue
+ val reviewsList = mutableListOf()
+ for (reviewSnapshot in placeSnapshot.children) {
+ val r = reviewSnapshot.getValue(com.appnotresponding.rumbo.models.Review::class.java)
+ if (r != null) reviewsList.add(r)
+ }
+ map[placeId] = reviewsList
+ }
+ firebaseReviews = map
+ mergePlacesWithReviews()
+ }
+
+ override fun onCancelled(error: com.google.firebase.database.DatabaseError) {
+ android.util.Log.e("PlacesViewModel", "Error fetching reviews: ${error.message}")
+ }
+ })
+ }
+
+ fun updatePlaces(list: List) {
+ rawNearbyPlaces = list
+ mergePlacesWithReviews()
+ }
+
+ private fun mergePlacesWithReviews() {
+ _uiState.update { currentState ->
+ val activeList = if (currentState.searchQuery.isBlank()) rawNearbyPlaces else rawSearchPlaces
+ val merged = activeList.map { place ->
+ val reviewsForPlace = (firebaseReviews[place.id] ?: emptyList()).sortedByDescending { it.time }
+ val newRating = if (reviewsForPlace.isNotEmpty()) reviewsForPlace.map { it.rating }.average() else place.rating
+ place.copy(reviews = reviewsForPlace, rating = if (newRating?.isNaN() == true) 0.0 else newRating)
+ }
+ val mergedSelected = merged.find { it.id == currentState.selectedPlace?.id }
+ val mergedPreviewed = merged.find { it.id == currentState.previewedPlace?.id }
+
+ currentState.copy(
+ availablePlaces = merged,
+ selectedPlace = mergedSelected ?: currentState.selectedPlace,
+ previewedPlace = mergedPreviewed ?: currentState.previewedPlace
+ )
+ }
+ }
+
+ fun onSearchQueryChanged(query: String, context: android.content.Context) {
+ _uiState.update { it.copy(searchQuery = query) }
+ if (query.isBlank()) {
+ rawSearchPlaces = emptyList()
+ mergePlacesWithReviews()
+ } else {
+ com.appnotresponding.rumbo.ui.utils.searchTextPlaces(
+ query = query,
+ context = context,
+ onPlacesReceived = { places ->
+ rawSearchPlaces = places
+ mergePlacesWithReviews()
+ },
+ onError = { error ->
+ android.util.Log.e("PlacesViewModel", "Error searching places: $error")
+ }
+ )
+ }
+ }
+
+ fun addToItinerary(place: Place) {
+ val current = _uiState.value.itinerary
+ if (current.none { it.id == place.id }) {
+ val updated = current + place
+ _uiState.update { it.copy(itinerary = updated) }
+ itineraryDbRef?.child("itinerary")?.setValue(updated)
+ }
+ }
+
+ fun removeFromItinerary(place: Place) {
+ val current = _uiState.value.itinerary
+ if (current.any { it.id == place.id }) {
+ val updated = current.filterNot { it.id == place.id }
+ _uiState.update { it.copy(itinerary = updated) }
+ itineraryDbRef?.child("itinerary")?.setValue(updated)
+ }
+ }
+
+ fun selectForNavigation(place: Place?) {
+ _uiState.update { currentState ->
+ val current = currentState.itinerary
+ val newItinerary = if (place != null && current.none { it.id == place.id }) {
+ current + place
+ } else {
+ current
+ }
+ currentState.copy(
+ selectedPlace = place,
+ itinerary = newItinerary
+ )
+ }
+ if (place != null) {
+ val current = _uiState.value.itinerary
+ itineraryDbRef?.child("itinerary")?.setValue(current)
+ }
+ itineraryDbRef?.child("selectedPlace")?.setValue(place)
+ }
+
+ fun showPreview(place: Place?) {
+ if (place == null) {
+ _uiState.update { it.copy(previewedPlace = null) }
+ return
+ }
+ val reviewsForPlace = (firebaseReviews[place.id] ?: emptyList()).sortedByDescending { it.time }
+ val newRating = if (reviewsForPlace.isNotEmpty()) reviewsForPlace.map { it.rating }.average() else place.rating
+ val mergedPlace = place.copy(reviews = reviewsForPlace, rating = if (newRating?.isNaN() == true) 0.0 else newRating)
+ _uiState.update { it.copy(previewedPlace = mergedPlace) }
+ }
+
+ fun searchAndShowNearestPlace(
+ latitude: Double,
+ longitude: Double,
+ radius: Double,
+ context: android.content.Context
+ ) {
+ com.appnotresponding.rumbo.ui.utils.searchNearbyPlaces(
+ latitude = latitude,
+ longitude = longitude,
+ radius = radius,
+ context = context,
+ onPlacesReceived = { places ->
+ if (places.isNotEmpty()) {
+ val pressLatLng = LatLng(latitude, longitude)
+ val nearestPlace = places.minByOrNull { place ->
+ com.google.maps.android.SphericalUtil.computeDistanceBetween(
+ pressLatLng,
+ LatLng(place.latitude, place.longitude)
+ )
+ }
+ showPreview(nearestPlace)
+ } else {
+ android.util.Log.d("PlacesViewModel", "No places found near the long press location.")
+ }
+ },
+ onError = { error ->
+ android.util.Log.e("PlacesViewModel", "Error fetching places for long press: $error")
+ }
+ )
+ }
+
+ fun clearForNavigation() {
+ _uiState.update { it.copy(selectedPlace = null) }
+ itineraryDbRef?.child("selectedPlace")?.setValue(null)
+ }
+
+ private fun observeItineraryAndSelectedPlace() {
+ auth.addAuthStateListener { firebaseAuth ->
+ val uid = firebaseAuth.currentUser?.uid
+
+ itineraryListener?.let { listener ->
+ itineraryDbRef?.removeEventListener(listener)
+ }
+ itineraryDbRef = null
+ itineraryListener = null
+
+ if (uid != null) {
+ itineraryDbRef = FirebaseDatabase.getInstance().getReference("itineraries").child(uid)
+ itineraryListener = object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ val itineraryList = mutableListOf()
+ val itinerarySnapshot = snapshot.child("itinerary")
+ for (child in itinerarySnapshot.children) {
+ val place = child.getValue(Place::class.java)
+ if (place != null) {
+ itineraryList.add(place)
+ }
+ }
+
+ val selectedPlace = snapshot.child("selectedPlace").getValue(Place::class.java)
+
+ _uiState.update { currentState ->
+ currentState.copy(
+ itinerary = itineraryList,
+ selectedPlace = selectedPlace
+ )
+ }
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ android.util.Log.e("PlacesViewModel", "Error fetching itinerary: ${error.message}")
+ }
+ }
+ itineraryDbRef?.addValueEventListener(itineraryListener!!)
+ } else {
+ _uiState.update { currentState ->
+ currentState.copy(
+ itinerary = emptyList(),
+ 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) }
+ }
+
+ fun uploadAndSaveReview(
+ placeId: String,
+ review: com.appnotresponding.rumbo.models.Review,
+ imageUri: android.net.Uri?,
+ onSuccess: () -> Unit,
+ onFailure: (String) -> Unit
+ ) {
+ val reviewId = java.util.UUID.randomUUID().toString()
+ val dbRef = com.google.firebase.database.FirebaseDatabase.getInstance().getReference("places_reviews").child(placeId).child(reviewId)
+
+ if (imageUri != null) {
+ val storageRef = com.google.firebase.storage.FirebaseStorage.getInstance("gs://rumbowapp.firebasestorage.app")
+ .getReference("reviews/$reviewId.jpg")
+ storageRef.putFile(imageUri)
+ .addOnSuccessListener {
+ storageRef.downloadUrl.addOnSuccessListener { downloadUrl ->
+ val finalReview = review.copy(id = reviewId, photoUrl = downloadUrl.toString(), time = System.currentTimeMillis())
+ dbRef.setValue(finalReview).addOnCompleteListener {
+ onSuccess()
+ }
+ }.addOnFailureListener { e ->
+ onFailure(e.message ?: "Error al obtener URL")
+ }
+ }
+ .addOnFailureListener { e ->
+ onFailure(e.message ?: "Error al subir imagen")
+ }
+ } else {
+ val finalReview = review.copy(id = reviewId, photoUrl = null, time = System.currentTimeMillis())
+ dbRef.setValue(finalReview).addOnCompleteListener {
+ onSuccess()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userLocationViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userLocationViewModel.kt
new file mode 100644
index 0000000..978c640
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userLocationViewModel.kt
@@ -0,0 +1,102 @@
+package com.appnotresponding.rumbo.ui.viewModel
+
+import android.app.Application
+import android.content.pm.PackageManager
+import android.os.Looper
+import android.util.Log
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.AndroidViewModel
+import com.appnotresponding.rumbo.ui.utils.createLocationCallback
+import com.appnotresponding.rumbo.ui.utils.createLocationRequest
+import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.tasks.Task
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.database.FirebaseDatabase
+import com.google.firebase.database.DataSnapshot
+import com.google.firebase.database.DatabaseError
+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 UserLocationState(
+ val latitude: Double = 0.0,
+ val longitude: Double = 0.0,
+ val altitude: Double = 0.0,
+)
+
+// https://stackoverflow.com/questions/51451819/how-to-get-context-in-android-mvvm-viewmodel
+class UserLocationViewModel(application: Application) : AndroidViewModel(application) {
+ private val _uiState = MutableStateFlow(UserLocationState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ val context = getApplication()
+ val locationClient = LocationServices.getFusedLocationProviderClient(context)
+ val locationCallback = createLocationCallback { result ->
+
+ result.lastLocation?.let { location ->
+
+ _uiState.update {
+ it.copy(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ altitude = location.altitude
+ )
+ }
+ Log.i("ULViewModel", "Ubicación recibida: ${location.latitude}, ${location.longitude}")
+
+ val userId = FirebaseAuth.getInstance().currentUser?.uid
+ if (userId != null) {
+ val dbRef = FirebaseDatabase.getInstance().getReference("users").child(userId)
+ dbRef.addListenerForSingleValueEvent(object : ValueEventListener {
+ override fun onDataChange(snapshot: DataSnapshot) {
+ if (snapshot.exists()) {
+ val updates = mapOf(
+ "latitude" to location.latitude,
+ "longitude" to location.longitude,
+ "altitude" to location.altitude
+ )
+ dbRef.updateChildren(updates).addOnFailureListener { e ->
+ Log.e("ULViewModel", "Error actualizando ubicación en DB", e)
+ }
+ } else {
+ Log.d("ULViewModel", "El nodo de usuario no existe aún en la base de datos. No se actualiza ubicación para evitar nodos huérfanos.")
+ }
+ }
+
+ override fun onCancelled(error: DatabaseError) {
+ Log.e("ULViewModel", "Error al leer existencia del usuario en DB: ${error.message}")
+ }
+ })
+ }
+ }
+ }
+
+ var locationRequest: LocationRequest = createLocationRequest()
+ var permissionGranted = false
+ var vel: Task? = null
+
+
+ fun updateVel() {
+ Log.i("Informativo", "Logrado")
+ if (!permissionGranted) {
+ permissionGranted = true
+ if (ContextCompat.checkSelfPermission(
+ context, android.Manifest.permission.ACCESS_FINE_LOCATION
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ Log.i("Informativo", "Logrado2")
+ vel = locationClient.requestLocationUpdates(
+ locationRequest, locationCallback, Looper.getMainLooper()
+ )
+ }
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ locationClient.removeLocationUpdates(locationCallback)
+ }
+}
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
new file mode 100644
index 0000000..bc7f0b0
--- /dev/null
+++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt
@@ -0,0 +1,117 @@
+package com.appnotresponding.rumbo.ui.viewModel
+
+import androidx.lifecycle.ViewModel
+import com.appnotresponding.rumbo.models.User
+import com.appnotresponding.rumbo.ui.utils.toUser
+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
+import kotlinx.coroutines.flow.asStateFlow
+
+class UserViewModel : ViewModel() {
+ private val auth = FirebaseAuth.getInstance()
+ 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()
+
+ init {
+ // Escucha cambios en el estado de autenticación de Firebase
+ auth.addAuthStateListener { firebaseAuth ->
+ 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)
+ userStatusRef.child("id").setValue(uid)
+ }
+
+ 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) {
+ try {
+ val user = snapshot.toUser(uid)
+ android.util.Log.d("UserViewModel", "fetchUserData success: user=${user.name}, id=${user.id}, 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/brand.xml b/app/src/main/res/drawable/brand.xml
new file mode 100644
index 0000000..fd9fa64
--- /dev/null
+++ b/app/src/main/res/drawable/brand.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_calendar.xml b/app/src/main/res/drawable/ic_calendar.xml
new file mode 100644
index 0000000..d27abb9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_calendar.xml
@@ -0,0 +1,9 @@
+
+
+
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_destroy.xml b/app/src/main/res/drawable/ic_destroy.xml
new file mode 100644
index 0000000..cd4cd12
--- /dev/null
+++ b/app/src/main/res/drawable/ic_destroy.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_heat.xml b/app/src/main/res/drawable/ic_heat.xml
new file mode 100644
index 0000000..173eced
--- /dev/null
+++ b/app/src/main/res/drawable/ic_heat.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml
new file mode 100644
index 0000000..86fd88a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_logout.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_minus.xml b/app/src/main/res/drawable/ic_minus.xml
new file mode 100644
index 0000000..41a21cf
--- /dev/null
+++ b/app/src/main/res/drawable/ic_minus.xml
@@ -0,0 +1,9 @@
+
+
+
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/ic_recuerdos.xml b/app/src/main/res/drawable/ic_recuerdos.xml
new file mode 100644
index 0000000..fb4bf9d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_recuerdos.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/outline_tour_24.xml b/app/src/main/res/drawable/outline_tour_24.xml
new file mode 100644
index 0000000..4545783
--- /dev/null
+++ b/app/src/main/res/drawable/outline_tour_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/map_resources.xml b/app/src/main/res/values/map_resources.xml
new file mode 100644
index 0000000..220733a
--- /dev/null
+++ b/app/src/main/res/values/map_resources.xml
@@ -0,0 +1,4 @@
+
+
+ 6963c8bb72d60b91e1a1fc2f
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml
new file mode 100644
index 0000000..c227121
--- /dev/null
+++ b/app/src/main/res/xml/provider_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
index 18318be..81b427f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -2,4 +2,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.google.gms.google.services) apply false
}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b4508d6..09c5da0 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-agp = "9.0.1"
+agp = "9.0.0"
coilCompose = "3.4.0"
coreKtx = "1.17.0"
junit = "4.13.2"
@@ -9,15 +9,31 @@ lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.4"
kotlin = "2.3.10"
composeBom = "2026.02.00"
+mapsCompose = "8.3.0"
+playServicesMaps = "20.0.0"
+securityCrypto = "1.1.0"
uiTextGoogleFonts = "1.10.3"
ui = "1.10.3"
uiText = "1.10.3"
navigationCompose = "2.9.7"
material3 = "1.4.0"
foundationLayout = "1.10.4"
+playServicesLocation = "21.3.0"
+accompanistPermissions = "0.34.0"
+googleGmsGoogleServices = "4.4.4"
+firebaseAuth = "24.0.1"
+credentials = "1.6.0"
+credentialsPlayServicesAuth = "1.6.0"
+googleid = "1.2.0"
+secretsPlugin = "2.0.1"
+runtime = "1.11.2"
+firebaseDatabase = "22.0.1"
+firebaseStorage = "22.0.1"
+firebaseMessaging = "25.0.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilCompose" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
@@ -39,8 +55,23 @@ androidx-compose-ui-text = { group = "androidx.compose.ui", name = "ui-text", ve
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
+play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
+accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" }
+firebase-auth = { group = "com.google.firebase", name = "firebase-auth", version.ref = "firebaseAuth" }
+androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" }
+androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentialsPlayServicesAuth" }
+googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
+maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "mapsCompose" }
+maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "mapsCompose" }
+play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "playServicesMaps" }
+androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" }
+firebase-database = { group = "com.google.firebase", name = "firebase-database", version.ref = "firebaseDatabase" }
+firebase-storage = { group = "com.google.firebase", name = "firebase-storage", version.ref = "firebaseStorage" }
+firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging", version.ref = "firebaseMessaging" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+google-gms-google-services = { id = "com.google.gms.google-services", version.ref = "googleGmsGoogleServices" }
+mapsplatform-secrets-plugin = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsPlugin" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 11229b8..c640822 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -16,6 +16,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven("https://jitpack.io")
}
}