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