From 54e8ad9bce4b333d0248f852a68904a3510c38ef Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Tue, 21 Apr 2026 23:11:08 -0500 Subject: [PATCH 01/49] [Feat]:Uso sensor luz Uso completo de sensor de luz en la aplicacion --- .../appnotresponding/rumbo/MainActivity.kt | 41 ++++++++++++++++++- .../rumbo/ui/templates/MapTemplate.kt | 7 ++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt index e05b745..2b32edb 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt @@ -1,8 +1,15 @@ package com.appnotresponding.rumbo +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +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 @@ -12,12 +19,21 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme import com.google.firebase.auth.FirebaseAuth lateinit var auth: FirebaseAuth +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() { +class MainActivity : FragmentActivity(), SensorEventListener { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) auth = FirebaseAuth.getInstance() + + // Inicializar sensor + sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager + lightSensor = sensorManager.getDefaultSensor(Sensor.TYPE_LIGHT) + 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/ @@ -26,9 +42,30 @@ class MainActivity : FragmentActivity() { add(OkHttpNetworkFetcherFactory()) }.build() } - RumboTheme { + RumboTheme(darkTheme = isDarkTheme) { Navigation() } } } + + 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) {} } \ 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 d7db226..265454a 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 @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableIntStateOf @@ -29,6 +30,7 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.appnotresponding.rumbo.R +import com.appnotresponding.rumbo.isDarkTheme import com.appnotresponding.rumbo.models.User import com.appnotresponding.rumbo.models.samplePlace import com.appnotresponding.rumbo.models.sampleReview @@ -73,6 +75,11 @@ fun MapTemplate( val mapId = stringResource(R.string.map_id) // Controla el estilo de color del mapa (claro, oscuro o seguir el sistema) y se actualiza dinámicamente según los cambios en el sensor de luz ambiental + LaunchedEffect(isDarkTheme) { + currentMapStyle = if (isDarkTheme) MapColorScheme.DARK else MapColorScheme.LIGHT + } + + Scaffold( contentWindowInsets = WindowInsets(0), topBar = { MainTopBar(user, onProfileClick = onProfileClick) }, From 71d2913096e00231fd45bdc948a7578a91c60948 Mon Sep 17 00:00:00 2001 From: JDOG Date: Tue, 21 Apr 2026 23:57:13 -0500 Subject: [PATCH 02/49] [Feat]: Plan and intenerary useful --- app/src/main/assets/places.json | 266 ++++++++++++++++++ .../rumbo/models/placeState.kt | 7 + .../rumbo/navigation/navigation.kt | 14 +- .../molecules/itinerary/ItineraryItemCard.kt | 6 +- .../components/molecules/plan/PlanItemCard.kt | 9 +- .../organisms/itinerary/ItineraryOverview.kt | 9 +- .../components/organisms/plan/PlanPOIList.kt | 9 +- .../ui/screens/itinerary/ItineraryScreen.kt | 10 +- .../rumbo/ui/screens/plan/PlanScreen.kt | 14 +- .../rumbo/ui/templates/ItineraryTemplate.kt | 10 +- .../rumbo/ui/templates/PlanTemplate.kt | 10 +- .../rumbo/ui/utils/jsonFunc.kt | 33 +++ .../rumbo/ui/viewModel/placesViewModel.kt | 33 +++ gradle/libs.versions.toml | 2 +- 14 files changed, 404 insertions(+), 28 deletions(-) create mode 100644 app/src/main/assets/places.json create mode 100644 app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/utils/jsonFunc.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt 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/models/placeState.kt b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt new file mode 100644 index 0000000..d94f998 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt @@ -0,0 +1,7 @@ +package com.appnotresponding.rumbo.models + +data class PlaceState( + val availablePlaces: List = emptyList(), + val itinerary: List = emptyList(), + val selectedPlace: Place? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt index dad152f..1a9b9e4 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt @@ -1,6 +1,8 @@ package com.appnotresponding.rumbo.navigation import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -13,6 +15,11 @@ import com.appnotresponding.rumbo.ui.screens.map.MapScreen import com.appnotresponding.rumbo.ui.screens.onboarding.OnBoardingScreen import com.appnotresponding.rumbo.ui.screens.plan.PlanScreen import com.appnotresponding.rumbo.ui.screens.splash.SplashScreen +import com.appnotresponding.rumbo.ui.utils.loadPlaces +import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel +import androidx.lifecycle.ViewModel + +val placesViewModel: PlacesViewModel = PlacesViewModel() enum class AppScreens{ Splash, @@ -28,6 +35,9 @@ enum class AppScreens{ @Composable fun Navigation(){ + val context = LocalContext.current + var lista = loadPlaces(context) + placesViewModel.updatePlaces(lista) val navController = rememberNavController() NavHost(navController=navController, startDestination = AppScreens.Splash.name){ composable (route = AppScreens.Splash.name){ @@ -49,10 +59,10 @@ fun Navigation(){ ChatThreadScreen(navController) } composable(route = AppScreens.Plan.name){ - PlanScreen(navController) + PlanScreen(navController, placesViewModel) } composable(route = AppScreens.Itinerary.name){ - ItineraryScreen(navController) + ItineraryScreen(navController, placesViewModel) } composable(route = AppScreens.OnBoarding.name){ OnBoardingScreen(navController) 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..7c002a2 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 @@ -27,6 +27,7 @@ import com.appnotresponding.rumbo.models.samplePlace 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 /** * 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,7 +35,7 @@ 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) { Row(modifier = Modifier.fillMaxWidth()) { Box( @@ -90,7 +91,7 @@ fun ItineraryItemCard(p: Place) { } - +/** @Preview(showBackground = true, name = "ItineraryItemCard - Light") @Composable private fun ItineraryItemCardLightPreview() { @@ -106,3 +107,4 @@ private fun ItineraryItemCardDarkPreview() { ItineraryItemCard(p = samplePlace) } } +*/ \ No newline at end of file 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..b7fe83c 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 @@ -27,6 +27,7 @@ import com.appnotresponding.rumbo.models.samplePlace 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 /** * PlanItemCard.kt @@ -37,7 +38,7 @@ 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) { var icon = R.drawable.ic_plus var msg = "Añadir al Itinerario" @@ -87,9 +88,9 @@ fun PlanItemCard(p: Place) { ) RumboButton( text = msg, onClick = { - //Change icon to check icon = R.drawable.ic_check msg = "Añadido al Itinerario" + placesViewModel.addToItinerary(p) }, style = RumboButtonStyle.Secondary, icon = painterResource(icon) ) } @@ -97,6 +98,7 @@ fun PlanItemCard(p: Place) { } +/** @Preview(showBackground = true, name = "PlanItemCard - Light") @Composable private fun PlanItemCardLightPreview() { @@ -111,4 +113,5 @@ private fun PlanItemCardDarkPreview() { RumboTheme(darkTheme = true) { PlanItemCard(p = samplePlace) } -} \ No newline at end of file +} + */ \ No newline at end of file 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..33d171d 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 @@ -12,6 +12,7 @@ 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,17 +24,18 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme */ @Composable -fun ItineraryOverview(itineraryList: List) { +fun ItineraryOverview(itineraryList: List, placesViewModel: PlacesViewModel) { LazyColumn( modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { items(itineraryList) { place -> - ItineraryItemCard(p = place) + ItineraryItemCard(p = place, placesViewModel) } } } +/** @Preview(showBackground = true, name = "ItineraryOverview - Light") @Composable private fun ItineraryOverviewLightPreview() { @@ -52,4 +54,5 @@ private fun ItineraryOverviewDarkPreview() { itineraryList = listOf(samplePlace, samplePlace, samplePlace) ) } -} \ No newline at end of file +} + */ \ 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..940c735 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 @@ -12,6 +12,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 /** * @@ -23,18 +24,19 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme */ @Composable -fun PlanPOIList(places: List) { +fun PlanPOIList(places: List, placesViewModel: PlacesViewModel) { LazyColumn( modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { items(places) { place -> - PlanItemCard(p = place) + PlanItemCard(p = place, placesViewModel) } } } +/** @Preview(showBackground = true, name = "PlanPOIList - Light") @Composable private fun PlanPOIListLightPreview() { @@ -53,4 +55,5 @@ private fun PlanPOIListDarkPreview() { places = listOf(samplePlace, samplePlace, samplePlace) ) } -} \ No newline at end of file +} + */ \ No newline at end of file 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 25e1e00..7ab5a4b 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,22 @@ package com.appnotresponding.rumbo.ui.screens.itinerary import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.navigation.NavHostController import com.appnotresponding.rumbo.auth import com.appnotresponding.rumbo.models.samplePlace import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens import com.appnotresponding.rumbo.ui.templates.ItineraryTemplate +import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel @Composable -fun ItineraryScreen(controller: NavHostController) { +fun ItineraryScreen(controller: NavHostController, placesViewModel: PlacesViewModel) { + val state by placesViewModel.uiState.collectAsState() ItineraryTemplate( user = sampleUser.copy(name = "Ana"), - itineraryList = listOf(samplePlace, samplePlace, samplePlace), + itineraryList = state.itinerary, controller = controller, onProfileClick = { auth.signOut() @@ -20,5 +24,5 @@ fun ItineraryScreen(controller: NavHostController) { popUpTo(controller.graph.startDestinationId) { inclusive = true } launchSingleTop = true } - }) + }, placesViewModel) } \ 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 dc7126c..f6d9dce 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,24 +1,28 @@ package com.appnotresponding.rumbo.ui.screens.plan import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.appnotresponding.rumbo.auth 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.viewModel.PlacesViewModel @Composable -fun PlanScreen(controller: NavHostController) { +fun PlanScreen(controller: NavHostController, placesViewModel: PlacesViewModel) { + val state by placesViewModel.uiState.collectAsState() PlanTemplate( - user = sampleUser.copy(name = "Ana"), placesList = listOf( - samplePlace, samplePlace, samplePlace - ), // Simulamos una lista con 3 lugares + user = sampleUser.copy(name = "Ana"), + placesList = state.availablePlaces, controller = controller, onProfileClick = { auth.signOut() controller.navigate(AppScreens.Splash.name) { popUpTo(controller.graph.startDestinationId) { inclusive = true } launchSingleTop = true } - }) + }, placesViewModel) } \ No newline at end of file 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 dcecc68..d8c9513 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 @@ -22,6 +22,7 @@ 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 /** * @@ -39,7 +40,8 @@ fun ItineraryTemplate( user: User, itineraryList: List, controller: NavHostController, - onProfileClick: () -> Unit = {} + onProfileClick: () -> Unit = {}, + placesViewModel: PlacesViewModel ) { Scaffold( contentWindowInsets = WindowInsets(0), @@ -53,11 +55,12 @@ fun ItineraryTemplate( DayHeader(title = "Así Se Ve Tu Día") Spacer(modifier = Modifier.height(16.dp)) - ItineraryOverview(itineraryList = itineraryList) + ItineraryOverview(itineraryList = itineraryList, placesViewModel) } } } +/** @Preview(showBackground = true, name = "ItineraryTemplate - Light") @Composable private fun ItineraryTemplateLightPreview() { @@ -80,4 +83,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/PlanTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/PlanTemplate.kt index a9c52bc..7d7af6a 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 @@ -22,6 +22,7 @@ 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 /** * @@ -39,7 +40,8 @@ fun PlanTemplate( user: User, placesList: List, controller: NavHostController, - onProfileClick: () -> Unit = {} + onProfileClick: () -> Unit = {}, + placesViewModel: PlacesViewModel ) { Scaffold( contentWindowInsets = WindowInsets(0), @@ -54,11 +56,12 @@ fun PlanTemplate( Spacer(modifier = Modifier.height(16.dp)) - PlanPOIList(places = placesList) + PlanPOIList(places = placesList, placesViewModel) } } } +/** @Preview(showBackground = true, name = "PlanTemplate - Light") @Composable private fun PlanTemplateLightPreview() { @@ -81,4 +84,5 @@ private fun PlanTemplateDarkPreview() { 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/utils/jsonFunc.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/jsonFunc.kt new file mode 100644 index 0000000..28df646 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/jsonFunc.kt @@ -0,0 +1,33 @@ +package com.appnotresponding.rumbo.ui.utils + +import android.content.Context +import com.appnotresponding.rumbo.models.Place +import com.appnotresponding.rumbo.models.Review +import org.json.JSONArray +import org.json.JSONObject + +fun loadPlaces(context: Context): MutableList { + val places = mutableListOf() + + val jsonString = context.assets.open("places.json").bufferedReader().use { it.readText() } + var placesJsonArray = JSONArray(jsonString) + + for (i in 0..placesJsonArray.length() - 1) { + val placeObject = placesJsonArray.getJSONObject(i) + + val id = placeObject.getString("id") + val name = placeObject.getString("name") + val description = placeObject.getString("description") + val openHours = placeObject.getString("openHours") + val price = placeObject.getString("price") + val latitude = placeObject.getDouble("latitude") + val longitude = placeObject.getDouble("longitude") + val rating = placeObject.getDouble("rating").toFloat() + val reviews = emptyList() + val image = placeObject.getString("imageUrl") + val place = Place(id, name, description, openHours, price, latitude, longitude, rating, reviews, image) + places.add(place) + } + + return places +} \ 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..f0d2ec5 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt @@ -0,0 +1,33 @@ +package com.appnotresponding.rumbo.ui.viewModel + +import androidx.lifecycle.ViewModel +import com.appnotresponding.rumbo.models.Place +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 + +class PlacesViewModel : ViewModel() { + private val _uiState = MutableStateFlow(PlaceState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updatePlaces(list: List) { + _uiState.update { it.copy(availablePlaces = list) } + } + + fun addToItinerary(place: Place) { + val current = _uiState.value.itinerary + if (current.none { it.id == place.id }) { + _uiState.update { it.copy(itinerary = current + place) } + } + } + + fun selectForNavigation(place: Place) { + _uiState.update { it.copy(selectedPlace = place) } + } + + fun clearForNavigation() { + _uiState.update { it.copy(selectedPlace = null) } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b78264b..f569425 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.1.1" +agp = "9.0.0" coilCompose = "3.4.0" coreKtx = "1.17.0" junit = "4.13.2" From d37edaea20fd5fbceb19f7f2e331973a07727c6d Mon Sep 17 00:00:00 2001 From: JDOG Date: Wed, 22 Apr 2026 01:44:41 -0500 Subject: [PATCH 03/49] [Feat]: Map follows user --- app/build.gradle.kts | 2 + .../appnotresponding/rumbo/MainActivity.kt | 10 +- .../rumbo/navigation/navigation.kt | 2 +- .../molecules/itinerary/ItineraryItemCard.kt | 9 +- .../organisms/itinerary/ItineraryOverview.kt | 5 +- .../organisms/map/DropNoteComposer.kt | 30 ++- .../rumbo/ui/screens/map/MapScreen.kt | 7 +- .../rumbo/ui/templates/ItineraryTemplate.kt | 2 +- .../rumbo/ui/templates/MapTemplate.kt | 197 +++++++++++++++++- .../rumbo/ui/utils/MapFunc.kt | 25 +++ .../rumbo/ui/viewModel/mapViewModel.kt | 62 ++++++ settings.gradle.kts | 1 + 12 files changed, 328 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d7f0a23..d2809d0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -70,4 +70,6 @@ dependencies { implementation(libs.accompanist.permissions) implementation(libs.maps.compose) implementation(libs.play.services.maps) + implementation("org.osmdroid:osmdroid-android:6.1.16") + implementation("com.github.MKergall:osmbonuspack:6.8.0") } \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt index fe4a164..5ba2d17 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt @@ -1,6 +1,8 @@ package com.appnotresponding.rumbo +import android.location.Geocoder import android.os.Bundle +import android.os.StrictMode import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -10,13 +12,19 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory 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 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) 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/ 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 1a9b9e4..6505b8c 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt @@ -50,7 +50,7 @@ fun Navigation(){ SignUpScreen(navController) } composable (route = AppScreens.Map.name) { - MapScreen(navController) + MapScreen(navController, placesViewModel) } composable (route = AppScreens.Chat.name) { ChatListScreen(navController) 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 7c002a2..a188c5a 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 @@ -20,10 +20,12 @@ 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 @@ -35,7 +37,7 @@ import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel * @param p El objeto Place que contiene la información del lugar a mostrar. */ @Composable -fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel) { +fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel, controller: NavHostController) { Row(modifier = Modifier.fillMaxWidth()) { Box( @@ -82,7 +84,10 @@ fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel) { ) RumboButton( text = "Iniciar Desplazamiento", - onClick = { /* TODO */ }, + onClick = { + placesViewModel.selectForNavigation(p) + controller.navigate(AppScreens.Map.name) + }, style = RumboButtonStyle.Secondary, icon = painterResource(R.drawable.ic_map) ) 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 33d171d..35efaa0 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 @@ -8,6 +8,7 @@ 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 @@ -24,13 +25,13 @@ import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel */ @Composable -fun ItineraryOverview(itineraryList: List, placesViewModel: PlacesViewModel) { +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, placesViewModel) + ItineraryItemCard(p = place, placesViewModel, controller) } } } 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 349137f..f5d009b 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 @@ -36,6 +36,7 @@ fun DropNoteComposer( onValueChange: (String) -> Unit = {}, onSendClick: () -> Unit = {}, onImageClick: () -> Unit = {}, + onGalleryClick: () -> Unit = {}, imageUri: Uri? = null ) { Surface( @@ -44,9 +45,9 @@ fun DropNoteComposer( color = MaterialTheme.colorScheme.surfaceContainerHigh, 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( @@ -69,7 +70,8 @@ fun DropNoteComposer( } innerTextField() } - }) + } + ) if (imageUri != null) { AsyncImage( @@ -94,14 +96,24 @@ 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_add_image), + painter = painterResource(id = R.drawable.ic_picture), 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 = "Galería", + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } // Send button @@ -124,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/screens/map/MapScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/map/MapScreen.kt index 70f2f12..15f2c67 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 @@ -5,11 +5,13 @@ import androidx.navigation.NavHostController import com.appnotresponding.rumbo.auth import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens +import com.appnotresponding.rumbo.navigation.placesViewModel import com.appnotresponding.rumbo.ui.templates.MapTemplate +import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel @Composable fun MapScreen( - controller: NavHostController + controller: NavHostController, placesViewModel: PlacesViewModel ) { MapTemplate( user = sampleUser.copy(name = "Ana"), controller = controller, onProfileClick = { @@ -18,5 +20,6 @@ fun MapScreen( popUpTo(controller.graph.startDestinationId) { inclusive = true } launchSingleTop = true } - }) + }, + placesViewModel) } \ No newline at end of file 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 d8c9513..2f2e01a 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 @@ -55,7 +55,7 @@ fun ItineraryTemplate( DayHeader(title = "Así Se Ve Tu Día") Spacer(modifier = Modifier.height(16.dp)) - ItineraryOverview(itineraryList = itineraryList, placesViewModel) + ItineraryOverview(itineraryList = itineraryList, placesViewModel, controller) } } } 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 d7db226..685580e 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,18 +1,31 @@ package com.appnotresponding.rumbo.ui.templates +import android.content.pm.PackageManager +import android.hardware.Sensor +import android.hardware.SensorManager +import android.os.Looper import android.util.Log 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.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.material3.Button +import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableIntStateOf @@ -23,16 +36,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.appnotresponding.rumbo.R +import com.appnotresponding.rumbo.geocoder 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.molecules.map.LocateMe import com.appnotresponding.rumbo.ui.components.molecules.map.WriteDropNote import com.appnotresponding.rumbo.ui.components.organisms.common.MainTopBar @@ -41,8 +62,21 @@ 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.utils.SensorOverlay +import com.appnotresponding.rumbo.ui.utils.createLocationCallback +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.MapViewModel +import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel +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.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 import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.model.CameraPosition @@ -51,18 +85,36 @@ 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.Polyline import com.google.maps.android.compose.rememberCameraPositionState +import com.google.maps.android.compose.rememberUpdatedMarkerState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.osmdroid.util.GeoPoint + + +val locationPermission = android.Manifest.permission.ACCESS_FINE_LOCATION +var locationRequest : LocationRequest = createLocationRequest() @Composable -fun MapTemplate( - user: User, controller: NavHostController, onProfileClick: () -> Unit = {} +fun Mapa(user: User, + controller: NavHostController, + onProfileClick: () -> Unit = {}, + viewModel: MapViewModel = viewModel(), placesViewModel: PlacesViewModel ) { + var context = LocalContext.current + val state by viewModel.uiState.collectAsState() + val placesState by placesViewModel.uiState.collectAsState() + val locationClient = LocationServices.getFusedLocationProviderClient(context) var popupStateDNComposer by remember { mutableStateOf(false) } var popupStateReview by remember { mutableStateOf(false) } val locationState = rememberLocationManager() val mediaManager = rememberMediaHardwareManager() - LocalContext.current + var noteText by remember { mutableStateOf("") } var latitude by remember { mutableDoubleStateOf(4.627293) } var longitude by remember { mutableDoubleStateOf(-74.063228) } @@ -72,6 +124,47 @@ fun MapTemplate( var currentMapStyle by remember { mutableIntStateOf(MapColorScheme.FOLLOW_SYSTEM) } val mapId = stringResource(R.string.map_id) // Controla el estilo de color del mapa (claro, oscuro o seguir el sistema) y se actualiza dinámicamente según los cambios en el sensor de luz ambiental + val locationCallback = createLocationCallback { result -> + result.lastLocation?.let { + viewModel.updateUserMarker(it.latitude, it.longitude) + if (state.centerInUserFirstTime && (placesState.selectedPlace==null)) { + cameraPositionState.position = + CameraPosition.fromLatLngZoom(LatLng(it.latitude, it.longitude), 18f) + viewModel.updateCenterInUserFirstTime() + viewModel.updateLastSafeLatLng(it.latitude, it.longitude) + } + else if (state.centerInUserFirstTime && (placesState.selectedPlace!=null)) { + cameraPositionState.position = + CameraPosition.fromLatLngZoom(LatLng(placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude), 14f) + viewModel.updateCenterInUserFirstTime() + viewModel.updateLastSafeLatLng(it.latitude, it.longitude) + } + if(placesState.selectedPlace!=null) { + val startPoint = GeoPoint(it.latitude, it.longitude) + val destination = GeoPoint(placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude) + val points = arrayListOf(startPoint, destination) + val road = roadManager.getRoad(points) + val routePoints = road.mRouteHigh.map { geoPoint -> + LatLng(geoPoint.latitude, geoPoint.longitude) + } + viewModel.updateRoutePoints(routePoints) + } + } + } + + DisposableEffect(Unit) { + if(ContextCompat.checkSelfPermission(context, locationPermission)== PackageManager.PERMISSION_GRANTED) { + locationClient.requestLocationUpdates(locationRequest, locationCallback, Looper.getMainLooper()) + } + onDispose { + locationClient.removeLocationUpdates(locationCallback) + } + } + + if(placesState.selectedPlace!=null){ + viewModel.updateAdditionalMarker(LatLng(placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude), placesState.selectedPlace!!.name) + val startPoint = GeoPoint(placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude) + } Scaffold( contentWindowInsets = WindowInsets(0), @@ -88,7 +181,7 @@ fun MapTemplate( } LocateMe { if (locationState.hasPermission) { - // TODO: integrar con el mapa para centrar la camara en la ubicacion del usuario + cameraPositionState.position = CameraPosition.fromLatLngZoom(state.userMarker.position, 16f) Log.d( "MapTemplate", "Ubicacion: ${locationState.latitude}, ${locationState.longitude}" @@ -100,7 +193,6 @@ fun MapTemplate( } }, bottomBar = { Nav(controller) }) { paddingValues -> - // Main content area with the map Box( modifier = Modifier .fillMaxSize() @@ -128,6 +220,30 @@ fun MapTemplate( MapEffect(currentMapStyle) { googleMap -> googleMap.mapColorScheme = currentMapStyle } + Marker( + state = rememberUpdatedMarkerState(state.userMarker.position), + title = "User" + ) + Marker( + state = rememberUpdatedMarkerState(state.additionalMarker.position), + title = state.additionalMarker.title, + visible = state.additionalMarkerVisible + ) + if (state.routePoints.isNotEmpty()) { + Polyline( + points = state.routePoints, + color = Color.Blue, + width = 10f + ) + } + + if (state.userRouteVisible) { + Polyline( + points = state.userRoutePoints, + color = Color.Blue, + width = 10f + ) + } } SensorOverlay( modifier = Modifier @@ -145,7 +261,17 @@ fun MapTemplate( contentAlignment = Alignment.Center ) { DropNoteComposer( - onImageClick = { mediaManager.launchCamera() }, imageUri = mediaManager.imageUri + value = noteText, + onValueChange = { noteText = it }, + onImageClick = { mediaManager.launchCamera() }, + onGalleryClick = { mediaManager.launchGallery() }, + onSendClick = { + // TODO: enviar la nota + noteText = "" + mediaManager.clearImage() + popupStateDNComposer = false + }, + imageUri = mediaManager.imageUri ) } } @@ -162,6 +288,64 @@ fun MapTemplate( } } +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun MapTemplate( user: User, + controller: NavHostController, + onProfileClick: () -> Unit = {}, placesViewModel: PlacesViewModel){ + 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){ + Mapa( + user, + controller, + onProfileClick, placesViewModel = placesViewModel) + }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, + color = Color.Red, + textAlign = TextAlign.Center, + fontSize = 20.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, + color = Color.Red, + textAlign = TextAlign.Center, + fontSize = 20.sp) + } + } + } +} + + +/** @Preview(showBackground = true, name = "PlacePreviewCard - Light") @Composable private fun MapTemplateLightPreview() { @@ -181,3 +365,4 @@ private fun MapTemplateDarkPreview() { ) } } +*/ \ No newline at end of file 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..c921ec2 --- /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, 1000) + .setWaitForAccurateLocation(true) + .setMinUpdateIntervalMillis(500) + .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/viewModel/mapViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt new file mode 100644 index 0000000..e70b15d --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt @@ -0,0 +1,62 @@ +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 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) +) + +class MapViewModel: ViewModel() { + + private val _uiState = MutableStateFlow(MapState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updatePlace(place: String) { + _uiState.update { it.copy(place = place) } + } + + fun updateCenterInUserFirstTime() { + _uiState.update { it.copy(centerInUserFirstTime = false) } + } + + fun updateUserMarker(lat: Double, lng: Double) { + val newLatLng = LatLng(lat, lng) + _uiState.update { it.copy(userMarker = MyMarker(newLatLng)) } + } + + fun updateAdditionalMarker(position: LatLng, title: String) { + _uiState.update { it.copy(additionalMarker = MyMarker(position), additionalMarkerVisible = true) } + } + + 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/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") } } From 945b353c03417ab32402361f871fee853a9892f3 Mon Sep 17 00:00:00 2001 From: JDOG Date: Wed, 22 Apr 2026 01:59:48 -0500 Subject: [PATCH 04/49] [Feat]: Map permission keeping scaffold --- .../rumbo/ui/screens/map/MapScreen.kt | 2 +- .../rumbo/ui/templates/MapTemplate.kt | 175 +++++++++++------- 2 files changed, 113 insertions(+), 64 deletions(-) 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 15f2c67..7d249e0 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 @@ -21,5 +21,5 @@ fun MapScreen( launchSingleTop = true } }, - placesViewModel) + placesViewModel = placesViewModel) } \ 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 685580e..9e8c527 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 @@ -99,8 +99,9 @@ import org.osmdroid.util.GeoPoint val locationPermission = android.Manifest.permission.ACCESS_FINE_LOCATION var locationRequest : LocationRequest = createLocationRequest() +@OptIn(ExperimentalPermissionsApi::class) @Composable -fun Mapa(user: User, +fun MapTemplate(user: User, controller: NavHostController, onProfileClick: () -> Unit = {}, viewModel: MapViewModel = viewModel(), placesViewModel: PlacesViewModel @@ -124,6 +125,19 @@ fun Mapa(user: User, var currentMapStyle by remember { mutableIntStateOf(MapColorScheme.FOLLOW_SYSTEM) } val mapId = stringResource(R.string.map_id) // Controla el estilo de color del mapa (claro, oscuro o seguir el sistema) y se actualiza dinámicamente según los cambios en el sensor de luz ambiental + + 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() + } + } + } val locationCallback = createLocationCallback { result -> result.lastLocation?.let { viewModel.updateUserMarker(it.latitude, it.longitude) @@ -176,80 +190,114 @@ fun Mapa(user: User, .width(45.dp), verticalArrangement = Arrangement.spacedBy(30.dp) ) { - WriteDropNote { - popupStateDNComposer = !popupStateDNComposer - } - LocateMe { - if (locationState.hasPermission) { - cameraPositionState.position = CameraPosition.fromLatLngZoom(state.userMarker.position, 16f) - Log.d( - "MapTemplate", - "Ubicacion: ${locationState.latitude}, ${locationState.longitude}" - ) - } else { - locationState.requestPermission() + if(permission.status.isGranted) { + WriteDropNote { + popupStateDNComposer = !popupStateDNComposer + } + LocateMe { + if (locationState.hasPermission) { + cameraPositionState.position = + CameraPosition.fromLatLngZoom(state.userMarker.position, 16f) + Log.d( + "MapTemplate", + "Ubicacion: ${locationState.latitude}, ${locationState.longitude}" + ) + } else { + locationState.requestPermission() + } } } } }, bottomBar = { Nav(controller) }) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(top = paddingValues.calculateTopPadding() / 2) - ) { - GoogleMap( - modifier = Modifier.fillMaxSize(), - cameraPositionState = cameraPositionState, - contentDescription = "Mapa de Rumbo", - uiSettings = MapUiSettings( - zoomControlsEnabled = false, - myLocationButtonEnabled = false, - mapToolbarEnabled = false - ), - googleMapOptionsFactory = { - GoogleMapOptions().apply { - mapId(mapId) - mapType(GoogleMap.MAP_TYPE_NORMAL) + if(permission.status.isGranted) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = paddingValues.calculateTopPadding() / 2) + ) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + contentDescription = "Mapa de Rumbo", + 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 + } + Marker( + state = rememberUpdatedMarkerState(state.userMarker.position), + title = "User" + ) + Marker( + state = rememberUpdatedMarkerState(state.additionalMarker.position), + title = state.additionalMarker.title, + visible = state.additionalMarkerVisible + ) + if (state.routePoints.isNotEmpty()) { + Polyline( + points = state.routePoints, + color = Color.Blue, + width = 10f + ) + } + if (state.userRouteVisible) { + Polyline( + points = state.userRoutePoints, + color = Color.Blue, + width = 10f + ) } - }) { - // 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 } - Marker( - state = rememberUpdatedMarkerState(state.userMarker.position), - title = "User" - ) - Marker( - state = rememberUpdatedMarkerState(state.additionalMarker.position), - title = state.additionalMarker.title, - visible = state.additionalMarkerVisible + SensorOverlay( + modifier = Modifier + .align(Alignment.TopStart) + .padding(16.dp) ) - if (state.routePoints.isNotEmpty()) { - Polyline( - points = state.routePoints, - color = Color.Blue, - width = 10f - ) + } + } 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{ - if (state.userRouteVisible) { - Polyline( - points = state.userRoutePoints, - color = Color.Blue, - width = 10f - ) + Spacer(modifier = Modifier.height(25.dp)) + Text(message, + textAlign = TextAlign.Center, + fontSize = 15.sp) } } - SensorOverlay( - modifier = Modifier - .align(Alignment.TopStart) - .padding(16.dp) - ) } } if (popupStateDNComposer) { @@ -288,6 +336,7 @@ fun Mapa(user: User, } } +/** @OptIn(ExperimentalPermissionsApi::class) @Composable fun MapTemplate( user: User, @@ -343,7 +392,7 @@ fun MapTemplate( user: User, } } } - +*/ /** @Preview(showBackground = true, name = "PlacePreviewCard - Light") From 9b1b608d97a1e1ca201d60a08ec7d5a3f4323f0b Mon Sep 17 00:00:00 2001 From: JDOG Date: Wed, 22 Apr 2026 02:35:07 -0500 Subject: [PATCH 05/49] [Feat]: Map routes --- .../appnotresponding/rumbo/MainActivity.kt | 1 - .../molecules/map/MapFloatingActions.kt | 24 +++++++++++++++++++ .../ui/components/organisms/common/Nav.kt | 6 ++--- .../rumbo/ui/templates/MapTemplate.kt | 9 ++++++- .../main/res/drawable/outline_cancel_24.xml | 5 ++++ 5 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 app/src/main/res/drawable/outline_cancel_24.xml diff --git a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt index 6b9fe87..f49cd8b 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt @@ -25,7 +25,6 @@ import org.osmdroid.bonuspack.routing.OSRMRoadManager lateinit var auth: FirebaseAuth lateinit var geocoder: Geocoder lateinit var roadManager: OSRMRoadManager -class MainActivity : ComponentActivity() { lateinit var sensorManager: SensorManager var lightSensor: Sensor? = null var isDarkTheme by mutableStateOf(false) 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..a5a92b4 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 @@ -69,6 +69,24 @@ 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( + painter = painterResource(R.drawable.outline_cancel_24), + contentDescription = "Locate Me", + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } +} + @Preview(showBackground = true, name = "MapFloatingActions - Light") @Composable private fun MapFloatingActionsLightPreview() { @@ -82,6 +100,9 @@ private fun MapFloatingActionsLightPreview() { Box(modifier = Modifier.size(56.dp)) { LocateMe() } + Box(modifier = Modifier.size(56.dp)) { + CancelRoute() + } } } } @@ -99,6 +120,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/organisms/common/Nav.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt index 5613d83..a9681b6 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 @@ -148,11 +148,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 +180,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/templates/MapTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt index 13a136d..157d4bd 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 @@ -56,6 +56,7 @@ 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.molecules.map.CancelRoute import com.appnotresponding.rumbo.ui.components.molecules.map.LocateMe import com.appnotresponding.rumbo.ui.components.molecules.map.WriteDropNote import com.appnotresponding.rumbo.ui.components.organisms.common.MainTopBar @@ -193,11 +194,16 @@ fun MapTemplate(user: User, floatingActionButton = { Column( modifier = Modifier - .height(120.dp) + .height(150.dp) .width(45.dp), verticalArrangement = Arrangement.spacedBy(30.dp) ) { if(permission.status.isGranted) { + if(placesState.selectedPlace!=null){ + CancelRoute{ + placesViewModel.clearForNavigation() + } + } WriteDropNote { popupStateDNComposer = !popupStateDNComposer } @@ -213,6 +219,7 @@ fun MapTemplate(user: User, locationState.requestPermission() } } + } } }, diff --git a/app/src/main/res/drawable/outline_cancel_24.xml b/app/src/main/res/drawable/outline_cancel_24.xml new file mode 100644 index 0000000..0c7e845 --- /dev/null +++ b/app/src/main/res/drawable/outline_cancel_24.xml @@ -0,0 +1,5 @@ + + + + + From 944f811fb3a22a66e083280853bdfc79eb667866 Mon Sep 17 00:00:00 2001 From: JDOG Date: Wed, 22 Apr 2026 02:46:40 -0500 Subject: [PATCH 06/49] [Feat]: Map routes fix --- .../java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt | 1 + .../main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 157d4bd..8af609b 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 @@ -202,6 +202,7 @@ fun MapTemplate(user: User, if(placesState.selectedPlace!=null){ CancelRoute{ placesViewModel.clearForNavigation() + viewModel.updateRoutePoints(emptyList()) } } WriteDropNote { 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 index c921ec2..fe4ba7e 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt @@ -9,7 +9,7 @@ fun createLocationRequest() : LocationRequest{ val locationRequest = LocationRequest.Builder( Priority.PRIORITY_HIGH_ACCURACY, 1000) .setWaitForAccurateLocation(true) - .setMinUpdateIntervalMillis(500) + .setMinUpdateIntervalMillis(500).setMinUpdateDistanceMeters(10f) .build() return locationRequest } From 7eafc037bebfeadfeacb47fb4fe6b5b14d3de086 Mon Sep 17 00:00:00 2001 From: JDOG Date: Wed, 22 Apr 2026 02:55:35 -0500 Subject: [PATCH 07/49] [Fix]: Map routes fix --- .../com/appnotresponding/rumbo/ui/templates/MapTemplate.kt | 1 + .../com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt | 4 ++++ 2 files changed, 5 insertions(+) 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 8af609b..72ab48d 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 @@ -203,6 +203,7 @@ fun MapTemplate(user: User, CancelRoute{ placesViewModel.clearForNavigation() viewModel.updateRoutePoints(emptyList()) + viewModel.cancelAdditionalMarkerVisibility() } } WriteDropNote { 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 index e70b15d..4c391d3 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt @@ -43,6 +43,10 @@ class MapViewModel: ViewModel() { _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) } } From e3d67d99fe992f25a315a4298f10d3ca41b6b44f Mon Sep 17 00:00:00 2001 From: JDOG Date: Wed, 22 Apr 2026 05:25:03 -0500 Subject: [PATCH 08/49] [Fix]: Faster update initial location --- .../java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt | 2 +- .../main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 72ab48d..20ebbf3 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 @@ -144,11 +144,11 @@ fun MapTemplate(user: User, val locationCallback = createLocationCallback { result -> result.lastLocation?.let { viewModel.updateUserMarker(it.latitude, it.longitude) + viewModel.updateLastSafeLatLng(it.latitude, it.longitude) if (state.centerInUserFirstTime && (placesState.selectedPlace==null)) { cameraPositionState.position = CameraPosition.fromLatLngZoom(LatLng(it.latitude, it.longitude), 18f) viewModel.updateCenterInUserFirstTime() - viewModel.updateLastSafeLatLng(it.latitude, it.longitude) } else if (state.centerInUserFirstTime && (placesState.selectedPlace!=null)) { cameraPositionState.position = 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 index fe4ba7e..96240be 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt @@ -9,7 +9,7 @@ fun createLocationRequest() : LocationRequest{ val locationRequest = LocationRequest.Builder( Priority.PRIORITY_HIGH_ACCURACY, 1000) .setWaitForAccurateLocation(true) - .setMinUpdateIntervalMillis(500).setMinUpdateDistanceMeters(10f) + .setMinUpdateIntervalMillis(500) //.setMinUpdateDistanceMeters(10f) .build() return locationRequest } From 171d45dc2e040aeacd0492a69f8cd8d1c7a76eb7 Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Fri, 24 Apr 2026 15:30:40 -0500 Subject: [PATCH 09/49] Revert "[Merge]: Conflict fixing" This reverts commit d9adff5a7158810e514423a5173efb2b75480bf3, reversing changes made to 6dc09950ffa34c95af0f5ee34b442a95fde623e0. --- app/build.gradle.kts | 10 +- app/src/main/AndroidManifest.xml | 26 ++- .../appnotresponding/rumbo/MainActivity.kt | 4 +- .../appnotresponding/rumbo/models/logIn.kt | 158 ++++++++++++++++++ .../appnotresponding/rumbo/models/signUp.kt | 41 +++++ .../rumbo/navigation/navigation.kt | 6 +- .../ui/components/organisms/auth/LoginForm.kt | 92 +++++++--- .../components/organisms/auth/SignUpForm.kt | 17 +- .../ui/components/organisms/common/TopBar.kt | 5 +- .../organisms/map/DropNoteComposer.kt | 16 ++ .../rumbo/ui/screens/auth/LoginScreen.kt | 57 ++++++- .../rumbo/ui/screens/auth/SignUpScreen.kt | 25 ++- .../rumbo/ui/screens/chat/ChatListScreen.kt | 18 +- .../ui/screens/itinerary/ItineraryScreen.kt | 3 +- .../rumbo/ui/screens/map/MapScreen.kt | 1 + .../ui/screens/onboarding/OnBoardingScreen.kt | 9 +- .../rumbo/ui/screens/plan/PlanScreen.kt | 3 +- .../rumbo/ui/screens/splash/SplashScreen.kt | 23 +-- .../rumbo/ui/templates/AuthTemplate.kt | 15 +- .../rumbo/ui/templates/ChatTemplate.kt | 7 +- .../rumbo/ui/templates/ItineraryTemplate.kt | 5 +- .../rumbo/ui/templates/MapTemplate.kt | 23 ++- .../rumbo/ui/templates/PlanTemplate.kt | 5 +- .../rumbo/ui/utils/AccelerometerManager.kt | 59 +++++++ .../rumbo/ui/utils/CompassManager.kt | 104 ++++++++++++ .../rumbo/ui/utils/LocationManager.kt | 118 +++++++++++++ .../rumbo/ui/utils/MediaHardwareManager.kt | 71 ++++++++ .../rumbo/ui/utils/SensorOverlay.kt | 54 ++++++ app/src/main/res/values/map_resources.xml | 4 + app/src/main/res/xml/provider_paths.xml | 6 + build.gradle.kts | 1 + gradle/libs.versions.toml | 24 ++- 32 files changed, 913 insertions(+), 97 deletions(-) create mode 100644 app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/models/signUp.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/utils/AccelerometerManager.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/utils/CompassManager.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/utils/LocationManager.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/utils/MediaHardwareManager.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt create mode 100644 app/src/main/res/values/map_resources.xml create mode 100644 app/src/main/res/xml/provider_paths.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a059f36..83fbfb2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,8 @@ 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 { @@ -23,8 +25,7 @@ android { release { isMinifyEnabled = false proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } @@ -52,6 +53,11 @@ 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.googleid) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 33b175d..1902b82 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,9 +1,14 @@ - + + + + + + - + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt index 086c79c..f49cd8b 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt @@ -49,8 +49,8 @@ class MainActivity : FragmentActivity(), SensorEventListener { // 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(darkTheme = isDarkTheme) { Navigation() 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..7aeb961 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt @@ -0,0 +1,158 @@ +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 + +// 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 masterKey = + MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() + encryptedPrefs = EncryptedSharedPreferences.create( + context, + "rumbo_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + // Al inicializar, revisamos si ya hay credenciales guardadas + _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 { + 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 { + _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) { + encryptedPrefs?.edit()?.putString("email", email)?.putString("password", password)?.apply() + _loginState.update { it.copy(hasBiometricCredentials = true) } + } + + private fun getCredentials(): Pair? { + val email = encryptedPrefs?.getString("email", null) + val password = encryptedPrefs?.getString("password", null) + return if (email != null && password != null) Pair(email, password) else null + } + + private fun hasCredentials() = encryptedPrefs?.contains("email") == true + + private fun clearCredentials() { + encryptedPrefs?.edit()?.clear()?.apply() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/signUp.kt b/app/src/main/java/com/appnotresponding/rumbo/models/signUp.kt new file mode 100644 index 0000000..afec3a8 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/models/signUp.kt @@ -0,0 +1,41 @@ +package com.appnotresponding.rumbo.models + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class RegisterState( + val name: String = "", + val phone: String = "", + val email: String = "", + val password: String = "", + val country: String = "", +) + +class RegisterViewModel : ViewModel() { + private val _registerState = MutableStateFlow(RegisterState()) + fun updateName(newValue: String) { + _registerState.update { it.copy(name = newValue) } + } + + fun updatePhone(newValue: String) { + _registerState.update { it.copy(phone = newValue) } + } + + + fun updateEmail(newValue: String) { + _registerState.update { it.copy(email = newValue) } + } + + fun updatePassword(newValue: String) { + _registerState.update { it.copy(password = newValue) } + } + + fun updateCountry(newValue: String) { + _registerState.update { it.copy(country = newValue) } + } + + val registerState = _registerState.asStateFlow() + +} \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt index 35267bf..6505b8c 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt @@ -6,9 +6,7 @@ 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 @@ -46,7 +44,7 @@ fun Navigation(){ SplashScreen(navController) } composable(route = AppScreens.LogIn.name){ - LoginScreen(navController) + LogInScreen(navController) } composable (route = AppScreens.SignUp.name){ SignUpScreen(navController) 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..a62a3f9 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 @@ -47,7 +47,8 @@ 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: (email: String, password: String) -> Unit = { _, _ -> }, ) { var fullName by remember { mutableStateOf("") } var phone by remember { mutableStateOf("") } @@ -60,6 +61,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 @@ -129,8 +138,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 +190,9 @@ fun SignUpForm( // Botón: Registrarse RumboButton( text = "Registrarse", - onClick = onClick, + onClick = { onClick(email, password) }, style = RumboButtonStyle.Primary, + enabled = isSignUpEnabled, modifier = Modifier.fillMaxWidth(), ) } 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..1f754f5 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 @@ -31,7 +32,7 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme * y la foto de perfil para el avatar. */ @Composable -fun MainTopBar(u: User) { +fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) { val bottomRoundedShape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) Surface( shape = bottomRoundedShape, color = MaterialTheme.colorScheme.surfaceContainerLow, @@ -59,7 +60,7 @@ fun MainTopBar(u: User) { style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface ) - Avatar(user = u) + Avatar(modifier = Modifier.clickable(onClick = onProfileClick), user = u) } } } 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 48ad787..f5d009b 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 @@ -69,6 +73,18 @@ fun DropNoteComposer( } ) + 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(), 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..66bf290 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,5 +1,6 @@ package com.appnotresponding.rumbo.ui.screens.auth +import android.util.Log import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -8,6 +9,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController +import com.appnotresponding.rumbo.auth import com.appnotresponding.rumbo.navigation.AppScreens import com.appnotresponding.rumbo.ui.components.organisms.auth.SignUpForm import com.appnotresponding.rumbo.ui.templates.AuthTemplate @@ -18,13 +20,32 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme fun SignUpScreen( controller: NavController ) { + AuthTemplate { val scrollState = rememberScrollState() SignUpForm( - onClick = { controller.navigate(AppScreens.OnBoarding.name) }, - modifier = Modifier + onClick = { email, password -> + auth.createUserWithEmailAndPassword(email, password).addOnCompleteListener { + if (it.isSuccessful) { + auth.signInWithEmailAndPassword(email, password) + .addOnCompleteListener { signInTask -> + if (signInTask.isSuccessful) { + controller.navigate(AppScreens.OnBoarding.name) + } else { + Log.e( + "SignUpScreen", + "Login después del registro fallido", + signInTask.exception + ) + } + } + } else { + Log.e("SignUpScreen", "Registro fallido", it.exception) + } + } + }, modifier = Modifier .verticalScroll(scrollState) .fillMaxSize() ) 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..2652153 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 @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController +import com.appnotresponding.rumbo.auth import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens import com.appnotresponding.rumbo.ui.components.organisms.chat.ChatList @@ -13,7 +14,7 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme @Composable -fun ChatListScreen( controller: NavHostController ) { +fun ChatListScreen(controller: NavHostController) { val currentUser = sampleUser.copy(name = "Ana") val mockChats = listOf( @@ -54,12 +55,17 @@ fun ChatListScreen( controller: NavHostController ) { currentUser = currentUser, title = "Chats", subtitle = "Ubicación actual: Bogotá", - controller = controller - ) { + controller = controller, + onProfileClick = { + auth.signOut() + controller.navigate(AppScreens.Splash.name) { + popUpTo(controller.graph.startDestinationId) { inclusive = true } + launchSingleTop = true + } + }) { ChatList( chatItems = mockChats, - onChatClick = { controller.navigate(AppScreens.ChatThread.name) } - ) + onChatClick = { controller.navigate(AppScreens.ChatThread.name) }) } } @@ -71,7 +77,7 @@ fun ChatListScreen( controller: NavHostController ) { ) @Composable private fun ChatListScreenPreview() { - RumboTheme(darkTheme = true) { + RumboTheme(darkTheme = true) { ChatListScreen(controller = rememberNavController()) } } 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 63a763b..7ab5a4b 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 @@ -4,9 +4,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController +import com.appnotresponding.rumbo.auth import com.appnotresponding.rumbo.models.samplePlace import com.appnotresponding.rumbo.models.sampleUser +import com.appnotresponding.rumbo.navigation.AppScreens import com.appnotresponding.rumbo.ui.templates.ItineraryTemplate import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel 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 36a972a..7d249e0 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 @@ -2,6 +2,7 @@ package com.appnotresponding.rumbo.ui.screens.map import androidx.compose.runtime.Composable import androidx.navigation.NavHostController +import com.appnotresponding.rumbo.auth import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens import com.appnotresponding.rumbo.navigation.placesViewModel 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 8e71d28..f6d9dce 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 @@ -5,9 +5,10 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.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.viewModel.PlacesViewModel 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..582bf0f 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 @@ -5,12 +5,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize 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 +26,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() + .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..402d4c2 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 @@ -25,13 +24,13 @@ fun ChatTemplate( subtitle: String, modifier: Modifier = Modifier, controller: NavHostController, + onProfileClick: () -> Unit = {}, content: @Composable () -> Unit ) { Scaffold( contentWindowInsets = WindowInsets(0), - topBar = { MainTopBar(u = currentUser) }, - bottomBar = { Nav(controller) } - ) { paddingValues -> + topBar = { MainTopBar(u = currentUser, onProfileClick = onProfileClick) }, + bottomBar = { Nav(controller) }) { paddingValues -> Column( modifier = modifier .fillMaxSize() 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 312f04c..2f2e01a 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 @@ -45,9 +45,8 @@ fun ItineraryTemplate( ) { Scaffold( contentWindowInsets = WindowInsets(0), - topBar = { MainTopBar(u = user) }, - bottomBar = { Nav(controller) } - ) { paddingValues -> + topBar = { MainTopBar(u = user, onProfileClick = onProfileClick) }, + bottomBar = { Nav(controller) }) { paddingValues -> Column( modifier = Modifier .fillMaxSize() 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 a5ecfe4..20ebbf3 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 @@ -6,7 +6,6 @@ import android.hardware.SensorManager import android.os.Looper import android.util.Log 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 @@ -29,6 +28,8 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +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 @@ -119,6 +120,14 @@ fun MapTemplate(user: User, val mediaManager = rememberMediaHardwareManager() var noteText by remember { mutableStateOf("") } + 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) // Controla el estilo de color del mapa (claro, oscuro o seguir el sistema) y se actualiza dinámicamente según los cambios en el sensor de luz ambiental var permission = rememberPermissionState(locationPermission) var showButton by remember { mutableStateOf(false) } @@ -181,7 +190,7 @@ fun MapTemplate(user: User, Scaffold( contentWindowInsets = WindowInsets(0), - topBar = { MainTopBar(user) }, + topBar = { MainTopBar(user, onProfileClick = onProfileClick) }, floatingActionButton = { Column( modifier = Modifier @@ -406,8 +415,9 @@ fun MapTemplate( user: User, @Composable private fun MapTemplateLightPreview() { RumboTheme(darkTheme = true) { - MapTemplate(sampleUser, - controller = rememberNavController()) + MapTemplate( + sampleUser, controller = rememberNavController() + ) } } @@ -415,8 +425,9 @@ private fun MapTemplateLightPreview() { @Composable private fun MapTemplateDarkPreview() { RumboTheme(darkTheme = false) { - MapTemplate(sampleUser, - controller = rememberNavController()) + MapTemplate( + sampleUser, controller = rememberNavController() + ) } } */ \ 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 8514d57..7d7af6a 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 @@ -45,9 +45,8 @@ fun PlanTemplate( ) { Scaffold( contentWindowInsets = WindowInsets(0), - topBar = { MainTopBar(u = user) }, - bottomBar = { Nav(controller) } - ) { paddingValues -> + topBar = { MainTopBar(u = user, onProfileClick = onProfileClick) }, + bottomBar = { Nav(controller) }) { paddingValues -> Column( modifier = Modifier .fillMaxSize() 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..fc05ab8 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/CompassManager.kt @@ -0,0 +1,104 @@ +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, val direction: String +) + +// grados a cardinalidad +private fun getCardinalDirection(degrees: Float): String { + return when { + degrees < 22.5f || degrees >= 337.5f -> "Norte ↑" + degrees < 67.5f -> "Noreste ↗" + degrees < 112.5f -> "Este →" + degrees < 157.5f -> "Sureste ↘" + degrees < 202.5f -> "Sur ↓" + degrees < 247.5f -> "Suroeste ↙" + degrees < 292.5f -> "Oeste ←" + else -> "Noroeste ↖" + } +} + +@Composable +fun rememberCompassManager(context: Context = LocalContext.current): CompassState { + var degrees by remember { mutableFloatStateOf(0f) } + + val sensorManager = remember { + context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + } + + val magnetometerSensor = remember { + sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) + } + + val accelerometerSensor = remember { + sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + } + + DisposableEffect(Unit) { + // El magnetometro necesita el acelerometro para calcular la orientacion correctamente + val gravity = FloatArray(3) + val geomagnetic = FloatArray(3) + + val listener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + when (event.sensor.type) { + 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] + } + } + + val rotationMatrix = FloatArray(9) + val inclinationMatrix = FloatArray(9) + val success = SensorManager.getRotationMatrix( + rotationMatrix, inclinationMatrix, gravity, geomagnetic + ) + + if (success) { + val orientation = FloatArray(3) + SensorManager.getOrientation(rotationMatrix, orientation) + // radianes a grados y se da el valor entre 0 y 360 + val azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat() + degrees = (azimuth + 360f) % 360f + } + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} + } + + sensorManager.registerListener( + listener, magnetometerSensor, SensorManager.SENSOR_DELAY_NORMAL + ) + sensorManager.registerListener( + listener, accelerometerSensor, SensorManager.SENSOR_DELAY_NORMAL + ) + + onDispose { + sensorManager.unregisterListener(listener) + } + } + + return CompassState( + degrees = degrees, direction = getCardinalDirection(degrees) + ) +} 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/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/SensorOverlay.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt new file mode 100644 index 0000000..6b17e6c --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt @@ -0,0 +1,54 @@ +package com.appnotresponding.rumbo.ui.utils + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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 + +@Composable +fun SensorOverlay(modifier: Modifier = Modifier) { + val accelerometerState = rememberAccelerometerManager() + val compassState = rememberCompassManager() + + Column( + modifier = modifier + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f), + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.Start + ) { + // Indicador de movimiento con el acelerometro + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = if (accelerometerState.isMoving) "🔴" else "🟢", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = if (accelerometerState.isMoving) "En movimiento" else "Quieto", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + // Indicador de brujula con el magnetometro + Text( + text = "Mirando hacia: ${compassState.direction}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} \ No newline at end of file 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..829fee8 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,27 @@ 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" [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 +51,18 @@ 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" } +play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "playServicesMaps" } [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" } From 6d2a4f067537e750b8befe1b631b5b9615e3ecda Mon Sep 17 00:00:00 2001 From: JDOG Date: Thu, 28 May 2026 09:46:06 -0500 Subject: [PATCH 10/49] [Fix]: InitPrefs catch exception --- .../appnotresponding/rumbo/models/logIn.kt | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt b/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt index 7aeb961..3a73239 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt @@ -41,17 +41,41 @@ class LoginViewModel : ViewModel() { //MasterKey: https://developer.android.com/reference/androidx/security/crypto/MasterKey fun initPrefs(context: Context) { if (encryptedPrefs != null) return - val masterKey = - MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() - encryptedPrefs = EncryptedSharedPreferences.create( - context, - "rumbo_secure_prefs", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - // Al inicializar, revisamos si ya hay credenciales guardadas - _loginState.update { it.copy(hasBiometricCredentials = hasCredentials()) } + try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + encryptedPrefs = EncryptedSharedPreferences.create( + context, + "rumbo_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + } catch (e: Exception) { + + // Borra archivo corrupto + context.deleteSharedPreferences("rumbo_secure_prefs") + + // Reintenta crear prefs limpias + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + encryptedPrefs = EncryptedSharedPreferences.create( + context, + "rumbo_secure_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + _loginState.update { + it.copy(hasBiometricCredentials = hasCredentials()) + } } fun updateEmail(newValue: String) { From 9b6390a66cbd009a98de98795178f11674c82b60 Mon Sep 17 00:00:00 2001 From: JDOG Date: Thu, 28 May 2026 10:21:46 -0500 Subject: [PATCH 11/49] [Feat]: Add userLocationViewModel --- .../rumbo/navigation/navigation.kt | 5 +- .../rumbo/ui/screens/map/MapScreen.kt | 6 +- .../rumbo/ui/templates/MapTemplate.kt | 8 +- .../ui/viewModel/userLocationViewModel.kt | 78 +++++++++++++++++++ 4 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userLocationViewModel.kt 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 6505b8c..bd2ab64 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt @@ -18,6 +18,7 @@ import com.appnotresponding.rumbo.ui.screens.splash.SplashScreen import com.appnotresponding.rumbo.ui.utils.loadPlaces import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel import androidx.lifecycle.ViewModel +import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel val placesViewModel: PlacesViewModel = PlacesViewModel() @@ -34,7 +35,7 @@ enum class AppScreens{ } @Composable -fun Navigation(){ +fun Navigation(locationViewModel: UserLocationViewModel = viewModel()){ val context = LocalContext.current var lista = loadPlaces(context) placesViewModel.updatePlaces(lista) @@ -50,7 +51,7 @@ fun Navigation(){ SignUpScreen(navController) } composable (route = AppScreens.Map.name) { - MapScreen(navController, placesViewModel) + MapScreen(navController, placesViewModel, locationViewModel) } composable (route = AppScreens.Chat.name) { ChatListScreen(navController) 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 7d249e0..c4507f2 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 @@ -8,10 +8,11 @@ import com.appnotresponding.rumbo.navigation.AppScreens import com.appnotresponding.rumbo.navigation.placesViewModel import com.appnotresponding.rumbo.ui.templates.MapTemplate import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel +import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel @Composable fun MapScreen( - controller: NavHostController, placesViewModel: PlacesViewModel + controller: NavHostController, placesViewModel: PlacesViewModel, locationViewModel: UserLocationViewModel ) { MapTemplate( user = sampleUser.copy(name = "Ana"), controller = controller, onProfileClick = { @@ -21,5 +22,6 @@ fun MapScreen( launchSingleTop = true } }, - placesViewModel = placesViewModel) + placesViewModel = placesViewModel, + locationViewModel = locationViewModel) } \ 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 20ebbf3..94b106b 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 @@ -71,6 +71,7 @@ import com.appnotresponding.rumbo.ui.utils.rememberLocationManager import com.appnotresponding.rumbo.ui.utils.rememberMediaHardwareManager 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 @@ -107,10 +108,11 @@ var locationRequest : LocationRequest = createLocationRequest() fun MapTemplate(user: User, controller: NavHostController, onProfileClick: () -> Unit = {}, - viewModel: MapViewModel = viewModel(), placesViewModel: PlacesViewModel + viewModel: MapViewModel = viewModel(), placesViewModel: PlacesViewModel, locationViewModel: UserLocationViewModel ) { var context = LocalContext.current val state by viewModel.uiState.collectAsState() + val userLocationState by locationViewModel.uiState.collectAsState() val placesState by placesViewModel.uiState.collectAsState() val locationClient = LocationServices.getFusedLocationProviderClient(context) @@ -141,6 +143,10 @@ fun MapTemplate(user: User, } } } + + if (permission.status.isGranted) { + if (!locationViewModel.permissionGranted) locationViewModel.updateVel() + } val locationCallback = createLocationCallback { result -> result.lastLocation?.let { viewModel.updateUserMarker(it.latitude, it.longitude) 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..4b7bc01 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userLocationViewModel.kt @@ -0,0 +1,78 @@ +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.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationServices +import com.appnotresponding.rumbo.ui.utils.createLocationCallback +import com.appnotresponding.rumbo.ui.utils.createLocationRequest +import com.google.android.gms.tasks.Task +import com.google.firebase.Firebase +import com.google.firebase.auth.FirebaseAuth +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}") + + } + } + + 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) + } +} \ No newline at end of file From aeca548bf1e277150b3ea1ee34b798341e6900f2 Mon Sep 17 00:00:00 2001 From: JDOG Date: Thu, 28 May 2026 10:36:32 -0500 Subject: [PATCH 12/49] [Feat]: Map Template User marker depending on userLocationViewModel --- .../rumbo/ui/templates/MapTemplate.kt | 58 ++++++++----------- .../rumbo/ui/utils/MapFunc.kt | 4 +- 2 files changed, 27 insertions(+), 35 deletions(-) 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 94b106b..02fd0ee 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 @@ -147,43 +147,35 @@ fun MapTemplate(user: User, if (permission.status.isGranted) { if (!locationViewModel.permissionGranted) locationViewModel.updateVel() } - val locationCallback = createLocationCallback { result -> - result.lastLocation?.let { - viewModel.updateUserMarker(it.latitude, it.longitude) - viewModel.updateLastSafeLatLng(it.latitude, it.longitude) - if (state.centerInUserFirstTime && (placesState.selectedPlace==null)) { - cameraPositionState.position = - CameraPosition.fromLatLngZoom(LatLng(it.latitude, it.longitude), 18f) - viewModel.updateCenterInUserFirstTime() - } - else if (state.centerInUserFirstTime && (placesState.selectedPlace!=null)) { - cameraPositionState.position = - CameraPosition.fromLatLngZoom(LatLng(placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude), 14f) - viewModel.updateCenterInUserFirstTime() - viewModel.updateLastSafeLatLng(it.latitude, it.longitude) - } - if(placesState.selectedPlace!=null) { - val startPoint = GeoPoint(it.latitude, it.longitude) - val destination = GeoPoint(placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude) - val points = arrayListOf(startPoint, destination) - val road = roadManager.getRoad(points) - val routePoints = road.mRouteHigh.map { geoPoint -> - LatLng(geoPoint.latitude, geoPoint.longitude) - } - viewModel.updateRoutePoints(routePoints) - } - } - } - DisposableEffect(Unit) { - if(ContextCompat.checkSelfPermission(context, locationPermission)== PackageManager.PERMISSION_GRANTED) { - locationClient.requestLocationUpdates(locationRequest, locationCallback, Looper.getMainLooper()) - } - onDispose { - locationClient.removeLocationUpdates(locationCallback) + viewModel.updateUserMarker(userLocationState.latitude, userLocationState.longitude) + viewModel.updateLastSafeLatLng(userLocationState.latitude, userLocationState.longitude) + if (state.centerInUserFirstTime && (placesState.selectedPlace==null)) { + cameraPositionState.position = + CameraPosition.fromLatLngZoom(LatLng(userLocationState.latitude, userLocationState.longitude), 18f) + viewModel.updateCenterInUserFirstTime() + } + else if (state.centerInUserFirstTime && (placesState.selectedPlace!=null)) { + cameraPositionState.position = + CameraPosition.fromLatLngZoom(LatLng(placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude), 14f) + viewModel.updateCenterInUserFirstTime() + viewModel.updateLastSafeLatLng(userLocationState.latitude, userLocationState.longitude) + } + if(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 road = roadManager.getRoad(points) + val routePoints = road.mRouteHigh.map { geoPoint -> + LatLng(geoPoint.latitude, geoPoint.longitude) } + viewModel.updateRoutePoints(routePoints) } + if(placesState.selectedPlace!=null){ viewModel.updateAdditionalMarker(LatLng(placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude), placesState.selectedPlace!!.name) val startPoint = GeoPoint(placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude) 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 index 96240be..cdfd18d 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt @@ -7,9 +7,9 @@ import com.google.android.gms.location.Priority fun createLocationRequest() : LocationRequest{ val locationRequest = LocationRequest.Builder( - Priority.PRIORITY_HIGH_ACCURACY, 1000) + Priority.PRIORITY_HIGH_ACCURACY, 2000) .setWaitForAccurateLocation(true) - .setMinUpdateIntervalMillis(500) //.setMinUpdateDistanceMeters(10f) + .setMinUpdateIntervalMillis(1000) //.setMinUpdateDistanceMeters(10f) .build() return locationRequest } From 01a186883564a7a5664b8274be098c0cbbe87475 Mon Sep 17 00:00:00 2001 From: JDOG Date: Thu, 28 May 2026 10:56:34 -0500 Subject: [PATCH 13/49] [Fix]: Move updates to launchEffect --- .../molecules/map/MapFloatingActions.kt | 2 +- .../rumbo/ui/templates/MapTemplate.kt | 18 +++++++++++++++--- .../appnotresponding/rumbo/ui/utils/MapFunc.kt | 2 +- 3 files changed, 17 insertions(+), 5 deletions(-) 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 a5a92b4..ed0c9cb 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 @@ -80,7 +80,7 @@ fun CancelRoute(onClick: () -> Unit = {}) { IconButton(onClick = onClick) { Icon( painter = painterResource(R.drawable.outline_cancel_24), - contentDescription = "Locate Me", + contentDescription = "Cancel Route", tint = MaterialTheme.colorScheme.onPrimary, ) } 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 02fd0ee..d65a7eb 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 @@ -110,11 +110,11 @@ fun MapTemplate(user: User, onProfileClick: () -> Unit = {}, viewModel: MapViewModel = viewModel(), placesViewModel: PlacesViewModel, locationViewModel: UserLocationViewModel ) { + Log.d("RECOMPOSE", "MapTemplate recomposed") var context = LocalContext.current val state by viewModel.uiState.collectAsState() val userLocationState by locationViewModel.uiState.collectAsState() val placesState by placesViewModel.uiState.collectAsState() - val locationClient = LocationServices.getFusedLocationProviderClient(context) var popupStateDNComposer by remember { mutableStateOf(false) } var popupStateReview by remember { mutableStateOf(false) } @@ -147,9 +147,21 @@ fun MapTemplate(user: User, if (permission.status.isGranted) { if (!locationViewModel.permissionGranted) locationViewModel.updateVel() } + LaunchedEffect( + userLocationState.latitude, + userLocationState.longitude + ) { + Log.d("RECOMPOSE", "Enntrando en launch") + viewModel.updateUserMarker( + userLocationState.latitude, + userLocationState.longitude + ) - viewModel.updateUserMarker(userLocationState.latitude, userLocationState.longitude) - viewModel.updateLastSafeLatLng(userLocationState.latitude, userLocationState.longitude) + viewModel.updateLastSafeLatLng( + userLocationState.latitude, + userLocationState.longitude + ) + } if (state.centerInUserFirstTime && (placesState.selectedPlace==null)) { cameraPositionState.position = CameraPosition.fromLatLngZoom(LatLng(userLocationState.latitude, userLocationState.longitude), 18f) 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 index cdfd18d..de37ff4 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt @@ -9,7 +9,7 @@ fun createLocationRequest() : LocationRequest{ val locationRequest = LocationRequest.Builder( Priority.PRIORITY_HIGH_ACCURACY, 2000) .setWaitForAccurateLocation(true) - .setMinUpdateIntervalMillis(1000) //.setMinUpdateDistanceMeters(10f) + .setMinUpdateIntervalMillis(1000) //.setMinUpdateDistanceMeters(10f) .build() return locationRequest } From 4afd4db7431d5182e1298bd3d760ce0aa01c7f9c Mon Sep 17 00:00:00 2001 From: JDOG Date: Thu, 28 May 2026 11:22:08 -0500 Subject: [PATCH 14/49] [Fix]: Minimize Recomposition in MapTemplate --- .../rumbo/ui/templates/MapTemplate.kt | 76 +++++++++++-------- .../rumbo/ui/utils/MapFunc.kt | 2 +- 2 files changed, 45 insertions(+), 33 deletions(-) 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 d65a7eb..04fae30 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 @@ -157,40 +157,52 @@ fun MapTemplate(user: User, userLocationState.longitude ) - viewModel.updateLastSafeLatLng( - userLocationState.latitude, - userLocationState.longitude - ) - } - if (state.centerInUserFirstTime && (placesState.selectedPlace==null)) { - cameraPositionState.position = - CameraPosition.fromLatLngZoom(LatLng(userLocationState.latitude, userLocationState.longitude), 18f) - viewModel.updateCenterInUserFirstTime() - } - else if (state.centerInUserFirstTime && (placesState.selectedPlace!=null)) { - cameraPositionState.position = - CameraPosition.fromLatLngZoom(LatLng(placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude), 14f) - viewModel.updateCenterInUserFirstTime() - viewModel.updateLastSafeLatLng(userLocationState.latitude, userLocationState.longitude) - } - if(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 road = roadManager.getRoad(points) - val routePoints = road.mRouteHigh.map { geoPoint -> - LatLng(geoPoint.latitude, geoPoint.longitude) + if (state.centerInUserFirstTime && (placesState.selectedPlace == null)) { + cameraPositionState.position = + CameraPosition.fromLatLngZoom( + LatLng( + userLocationState.latitude, + userLocationState.longitude + ), 18f + ) + viewModel.updateCenterInUserFirstTime() + } else if (state.centerInUserFirstTime && (placesState.selectedPlace != null)) { + cameraPositionState.position = + CameraPosition.fromLatLngZoom( + LatLng( + placesState.selectedPlace!!.latitude, + placesState.selectedPlace!!.longitude + ), 14f + ) + viewModel.updateCenterInUserFirstTime() + } + if (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 road = roadManager.getRoad(points) + val routePoints = road.mRouteHigh.map { geoPoint -> + LatLng(geoPoint.latitude, geoPoint.longitude) + } + viewModel.updateRoutePoints(routePoints) } - viewModel.updateRoutePoints(routePoints) - } - if(placesState.selectedPlace!=null){ - viewModel.updateAdditionalMarker(LatLng(placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude), placesState.selectedPlace!!.name) - val startPoint = GeoPoint(placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude) + if (placesState.selectedPlace != null) { + viewModel.updateAdditionalMarker( + LatLng( + placesState.selectedPlace!!.latitude, + placesState.selectedPlace!!.longitude + ), placesState.selectedPlace!!.name + ) + val startPoint = GeoPoint( + placesState.selectedPlace!!.latitude, + placesState.selectedPlace!!.longitude + ) + } } LaunchedEffect(isDarkTheme) { @@ -265,7 +277,7 @@ fun MapTemplate(user: User, googleMap.mapColorScheme = currentMapStyle } Marker( - state = rememberUpdatedMarkerState(state.userMarker.position), + state = rememberUpdatedMarkerState(LatLng(userLocationState.latitude, userLocationState.longitude)), title = "User" ) Marker( 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 index de37ff4..ae1172f 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/MapFunc.kt @@ -9,7 +9,7 @@ fun createLocationRequest() : LocationRequest{ val locationRequest = LocationRequest.Builder( Priority.PRIORITY_HIGH_ACCURACY, 2000) .setWaitForAccurateLocation(true) - .setMinUpdateIntervalMillis(1000) //.setMinUpdateDistanceMeters(10f) + .setMinUpdateIntervalMillis(1000).setMinUpdateDistanceMeters(10f) .build() return locationRequest } From c3f0d70109a9efe304762017c4aba25b16c85439 Mon Sep 17 00:00:00 2001 From: JDOG Date: Thu, 28 May 2026 11:27:42 -0500 Subject: [PATCH 15/49] [Feat] Add Google Places required dependencies --- app/build.gradle.kts | 2 + .../rumbo/ui/utils/placesAPI.kt | 274 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/utils/placesAPI.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 83fbfb2..2acb297 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,4 +73,6 @@ dependencies { implementation(libs.play.services.maps) 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/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..4dadfc4 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/placesAPI.kt @@ -0,0 +1,274 @@ +package com.appnotresponding.rumbo.ui.utils + +import android.util.Log +import androidx.compose.foundation.layout.add +import org.json.JSONArray +import org.json.JSONObject +/** +private fun getLocationAndSearch( + fusedLocationClient: com.google.android.gms.location.FusedLocationProviderClient, + onStatusChange: (String) -> Unit, + onPlacesReceived: (List) -> Unit +) { + + onStatusChange("Obteniendo ubicación...") + + try { + + fusedLocationClient.lastLocation + .addOnSuccessListener { location -> + + if (location != null) { + + val lat = location.latitude + val lng = location.longitude + + onStatusChange( + "Ubicación obtenida: $lat, $lng" + ) + + searchNearbyPlaces( + latitude = lat, + longitude = lng, + onPlacesReceived = onPlacesReceived, + onError = { + onStatusChange(it) + } + ) + + } else { + onStatusChange("No se pudo obtener ubicación") + } + } + + } catch (e: SecurityException) { + + onStatusChange(e.message ?: "Error de permisos") + } +} + +private fun searchNearbyPlaces( + latitude: Double, + longitude: Double, + onPlacesReceived: (List) -> Unit, + onError: (String) -> Unit +) { + + 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", 5000.0) + } + ) + } + ) + } + + val request = object : JsonObjectRequest( + Request.Method.POST, + url, + body, + + Response.Listener { response -> + + try { + + 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, + 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) +} + */ \ No newline at end of file From 2d7545493fcdb0b197a335e87a08d79b6010a77f Mon Sep 17 00:00:00 2001 From: JDOG Date: Thu, 28 May 2026 12:48:10 -0500 Subject: [PATCH 16/49] [Feat] Dynamic plan list --- app/build.gradle.kts | 20 +- .../appnotresponding/rumbo/MainActivity.kt | 2 +- .../appnotresponding/rumbo/models/place.kt | 18 +- .../rumbo/navigation/navigation.kt | 5 +- .../components/molecules/chat/ChatBubble.kt | 2 +- .../components/molecules/chat/ChatBubble.kt~ | 350 ------------------ .../molecules/itinerary/ItineraryItemCard.kt | 4 +- .../components/molecules/map/PlaceReview.kt | 2 +- .../components/molecules/plan/PlanItemCard.kt | 4 +- .../rumbo/ui/screens/chat/ChatThreadScreen.kt | 4 +- .../rumbo/ui/screens/plan/PlanScreen.kt | 47 ++- .../rumbo/ui/utils/jsonFunc.kt | 5 +- .../rumbo/ui/utils/placesAPI.kt | 65 +--- gradle/libs.versions.toml | 2 + 14 files changed, 107 insertions(+), 423 deletions(-) delete mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt~ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2acb297..6983633 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) @@ -10,7 +12,9 @@ android { compileSdk { version = release(36) } - + buildFeatures { + buildConfig = true + } defaultConfig { applicationId = "com.appnotresponding.rumbo" minSdk = 26 @@ -19,6 +23,19 @@ 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 { @@ -58,6 +75,7 @@ dependencies { implementation(libs.androidx.credentials.play.services.auth) implementation(libs.androidx.security.crypto) implementation(libs.googleid) + implementation(libs.androidx.compose.runtime) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt index f49cd8b..0e6f835 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt @@ -17,6 +17,7 @@ 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 @@ -31,7 +32,6 @@ var isDarkTheme by mutableStateOf(false) // https://developer.android.com/reference/androidx/fragment/app/FragmentActivity class MainActivity : FragmentActivity(), SensorEventListener { - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) auth = FirebaseAuth.getInstance() 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..d2947cc 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/place.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/place.kt @@ -3,25 +3,27 @@ package com.appnotresponding.rumbo.models data class Place( val id: String, val name: String, - val description: String, - val openHours: String, - val price: String, + val address: String, + val description: String?, + val openHours: List?, + val price: String?, val latitude: Double, val longitude: Double, - val rating: Float, + val rating: Double?, val reviews: List, - val imageUrl: String + val imageUrl: String? ) 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/navigation/navigation.kt b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt index bd2ab64..a3db31d 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt @@ -15,7 +15,6 @@ import com.appnotresponding.rumbo.ui.screens.map.MapScreen import com.appnotresponding.rumbo.ui.screens.onboarding.OnBoardingScreen import com.appnotresponding.rumbo.ui.screens.plan.PlanScreen import com.appnotresponding.rumbo.ui.screens.splash.SplashScreen -import com.appnotresponding.rumbo.ui.utils.loadPlaces import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel import androidx.lifecycle.ViewModel import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel @@ -37,8 +36,6 @@ enum class AppScreens{ @Composable fun Navigation(locationViewModel: UserLocationViewModel = viewModel()){ val context = LocalContext.current - var lista = loadPlaces(context) - placesViewModel.updatePlaces(lista) val navController = rememberNavController() NavHost(navController=navController, startDestination = AppScreens.Splash.name){ composable (route = AppScreens.Splash.name){ @@ -60,7 +57,7 @@ fun Navigation(locationViewModel: UserLocationViewModel = viewModel()){ ChatThreadScreen(navController) } composable(route = AppScreens.Plan.name){ - PlanScreen(navController, placesViewModel) + PlanScreen(navController, placesViewModel, locationViewModel) } composable(route = AppScreens.Itinerary.name){ ItineraryScreen(navController, placesViewModel) 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..d2f49ab 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 @@ -269,7 +269,7 @@ fun ChatBubble( color = contentColor ) Text( - text = place.price, + text = place.price ?: "No hay", style = MaterialTheme.typography.labelMedium, color = contentColor.copy(alpha = 0.7f) ) 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/itinerary/ItineraryItemCard.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/itinerary/ItineraryItemCard.kt index a188c5a..42f489f 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 @@ -73,12 +73,12 @@ fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel, controller: Na color = MaterialTheme.colorScheme.onBackground ) Text( - text = p.openHours, + text = p.openHours.toString(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground ) Text( - text = p.price, + text = p.price ?: "No hay información", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground ) 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..4dce223 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 @@ -76,7 +76,7 @@ fun PlaceInfo(p: Place) { color = MaterialTheme.colorScheme.onBackground ) Text( - text = p.description, + text = p.description ?: "No hay información", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground ) 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 b7fe83c..758c983 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 @@ -77,12 +77,12 @@ fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel) { color = MaterialTheme.colorScheme.onBackground ) Text( - text = p.description, + text = p.description ?: "No hay información", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground ) Text( - text = p.price, + text = p.price ?: "No hay información", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground ) 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..945c4d0 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 @@ -27,7 +27,7 @@ fun ChatThreadScreen( val museoNacional = samplePlace.copy( name = "Museo Nacional", - openHours = "9:00AM - 11:00AM", + openHours = emptyList(), price = "$ 40.000 COP" ) @@ -58,7 +58,7 @@ fun ChatThreadOneOnOnePreview() { val brandonUser = sampleUser.copy(name = "Brandon") val museoNacional = samplePlace.copy( - name = "Museo Nacional", openHours = "9:00AM - 11:00AM", price = "$ 40.000 COP" + name = "Museo Nacional", openHours = emptyList(), price = "$ 40.000 COP" ) val mockMessages = listOf( 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 f6d9dce..4c37ec3 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,23 +1,64 @@ 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.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.appnotresponding.rumbo.auth +import com.appnotresponding.rumbo.models.Place 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 @Composable -fun PlanScreen(controller: NavHostController, placesViewModel: PlacesViewModel) { - val state by placesViewModel.uiState.collectAsState() +fun PlanScreen(controller: NavHostController, placesViewModel: PlacesViewModel, locationViewModel: UserLocationViewModel) { + val context = LocalContext.current + + val userLocationState by locationViewModel.uiState.collectAsState() + val placesState by placesViewModel.uiState.collectAsState() + + 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.copy(name = "Ana"), - placesList = state.availablePlaces, + placesList = placesState.availablePlaces, controller = controller, onProfileClick = { auth.signOut() controller.navigate(AppScreens.Splash.name) { 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 index 28df646..542254b 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/jsonFunc.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/jsonFunc.kt @@ -6,6 +6,7 @@ import com.appnotresponding.rumbo.models.Review import org.json.JSONArray import org.json.JSONObject +/** fun loadPlaces(context: Context): MutableList { val places = mutableListOf() @@ -30,4 +31,6 @@ fun loadPlaces(context: Context): MutableList { } return places -} \ No newline at end of file +} + + */ \ 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 index 4dadfc4..36e3db0 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/placesAPI.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/placesAPI.kt @@ -1,58 +1,26 @@ 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 -/** -private fun getLocationAndSearch( - fusedLocationClient: com.google.android.gms.location.FusedLocationProviderClient, - onStatusChange: (String) -> Unit, - onPlacesReceived: (List) -> Unit -) { - - onStatusChange("Obteniendo ubicación...") - - try { - - fusedLocationClient.lastLocation - .addOnSuccessListener { location -> - - if (location != null) { - - val lat = location.latitude - val lng = location.longitude - - onStatusChange( - "Ubicación obtenida: $lat, $lng" - ) - - searchNearbyPlaces( - latitude = lat, - longitude = lng, - onPlacesReceived = onPlacesReceived, - onError = { - onStatusChange(it) - } - ) - - } else { - onStatusChange("No se pudo obtener ubicación") - } - } - - } catch (e: SecurityException) { +import com.appnotresponding.rumbo.BuildConfig - onStatusChange(e.message ?: "Error de permisos") - } -} - -private fun searchNearbyPlaces( +fun searchNearbyPlaces( latitude: Double, longitude: Double, onPlacesReceived: (List) -> Unit, - onError: (String) -> 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:searchNearby" @@ -85,7 +53,7 @@ private fun searchNearbyPlaces( } ) - put("radius", 5000.0) + put("radius", 2000.0) } ) } @@ -100,7 +68,10 @@ private fun searchNearbyPlaces( Response.Listener { response -> try { - + if (!response.has("places")) { + onError("La respuesta no contiene lugares") + return@Listener + } val placesJson = response.getJSONArray("places") val placesList = mutableListOf() @@ -208,6 +179,7 @@ private fun searchNearbyPlaces( latitude = latitude, longitude = longitude, rating = rating, + reviews = emptyList(), imageUrl = imageUrl ) ) @@ -271,4 +243,3 @@ private fun searchNearbyPlaces( requestQueue.add(request) } - */ \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 829fee8..f19de41 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ credentials = "1.6.0" credentialsPlayServicesAuth = "1.6.0" googleid = "1.2.0" secretsPlugin = "2.0.1" +runtime = "1.11.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -59,6 +60,7 @@ androidx-credentials-play-services-auth = { group = "androidx.credentials", name 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" } 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" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 0625d530556442a8cf0838ee8fda07543292a311 Mon Sep 17 00:00:00 2001 From: JDOG Date: Thu, 28 May 2026 14:41:07 -0500 Subject: [PATCH 17/49] [Feat]: Improve compass --- app/build.gradle.kts | 1 + .../rumbo/ui/utils/SensorOverlay.kt | 186 ++++++++++++++++-- .../rumbo/ui/utils/placesAPI.kt | 2 +- 3 files changed, 171 insertions(+), 18 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6983633..4752077 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,6 +89,7 @@ dependencies { implementation(libs.accompanist.permissions) implementation(libs.maps.compose) 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") 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 index 6b17e6c..30961b2 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt @@ -1,17 +1,31 @@ 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.Row +import androidx.compose.foundation.layout.Spacer +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.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.graphics.Color import androidx.compose.ui.unit.dp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Navigation @Composable fun SensorOverlay(modifier: Modifier = Modifier) { @@ -29,26 +43,164 @@ fun SensorOverlay(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.Start ) { // Indicador de movimiento con el acelerometro - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = if (accelerometerState.isMoving) "🔴" else "🟢", - style = MaterialTheme.typography.bodyLarge + CompassWidget( + degrees = compassState.degrees, + direction = compassState.direction + ) + } +} + +@Composable +fun CompassWidget( + degrees: Float, + direction: String, + modifier: Modifier = Modifier +) { + + val animatedRotation by animateFloatAsState( + targetValue = degrees, + label = "CompassRotation" + ) + Spacer(modifier = Modifier.height(30.dp)) + Box( + modifier = modifier + .size(82.dp) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.88f), + CircleShape ) - Text( - text = if (accelerometerState.isMoving) "En movimiento" else "Quieto", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + .border( + 1.5.dp, + MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), + CircleShape + ), + contentAlignment = Alignment.Center + ) { + + // Norte + Text( + text = "N", + modifier = Modifier.offset(y = (-28).dp), + color = Color.Red, + style = MaterialTheme.typography.labelSmall + ) + + // Sur + Text( + text = "S", + modifier = Modifier.offset(y = (28).dp), + style = MaterialTheme.typography.labelSmall + ) + + // Este + Text( + text = "E", + modifier = Modifier.offset(x = (28).dp), + style = MaterialTheme.typography.labelSmall + ) + + // Oeste + Text( + text = "O", + modifier = Modifier.offset(x = (-28).dp), + style = MaterialTheme.typography.labelSmall + ) + + // Flecha + Icon( + imageVector = Icons.Default.Navigation, + contentDescription = "Compass Arrow", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(36.dp) + .rotate(-animatedRotation) + ) + + // Punto central + Box( + modifier = Modifier + .size(6.dp) + .background( + MaterialTheme.colorScheme.primary, + CircleShape + ) + ) + } +} + +/** +@Composable +fun CompassWidget( + degrees: Float, + direction: String, + modifier: Modifier = Modifier +) { + + val animatedRotation by animateFloatAsState( + targetValue = degrees, + label = "CompassRotation" + ) + + Box( + modifier = modifier + .size(140.dp) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + CircleShape ) - } + .border( + 2.dp, + MaterialTheme.colorScheme.primary.copy(alpha = 0.4f), + CircleShape + ), + contentAlignment = Alignment.Center + ) { + + // Rosa de los vientos + Text( + text = "N", + modifier = Modifier.offset(y = (-50).dp), + color = Color.Red, + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = "S", + modifier = Modifier.offset(y = (50).dp), + style = MaterialTheme.typography.bodyMedium + ) - // Indicador de brujula con el magnetometro Text( - text = "Mirando hacia: ${compassState.direction}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + text = "E", + modifier = Modifier.offset(x = (50).dp), + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "O", + modifier = Modifier.offset(x = (-50).dp), + style = MaterialTheme.typography.bodyMedium + ) + + // Flecha + Icon( + Icons.Default.Navigation, + contentDescription = "Compass Arrow", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(70.dp) + .rotate(-animatedRotation) + ) + + // Centro + Box( + modifier = Modifier + .size(12.dp) + .background( + MaterialTheme.colorScheme.primary, + CircleShape + ) ) } -} \ No newline at end of file +} + */ \ 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 index 36e3db0..d32afef 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/placesAPI.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/placesAPI.kt @@ -53,7 +53,7 @@ fun searchNearbyPlaces( } ) - put("radius", 2000.0) + put("radius", 5000.0) } ) } From c25a859b032e0f31efca7b04de0fe7c87e3b71c6 Mon Sep 17 00:00:00 2001 From: JDOG Date: Fri, 29 May 2026 18:00:18 -0500 Subject: [PATCH 18/49] [Fix]: DNC as dialog --- .../rumbo/ui/templates/MapTemplate.kt | 60 +++++++++++++------ .../rumbo/ui/utils/SensorOverlay.kt | 1 - 2 files changed, 41 insertions(+), 20 deletions(-) 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 04fae30..0d1610b 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 @@ -43,6 +43,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview 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.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewmodel.compose.viewModel @@ -339,26 +341,46 @@ fun MapTemplate(user: User, } } if (popupStateDNComposer) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(0.75f)) - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - DropNoteComposer( - value = noteText, - onValueChange = { noteText = it }, - onImageClick = { mediaManager.launchCamera() }, - onGalleryClick = { mediaManager.launchGallery() }, - onSendClick = { - // TODO: enviar la nota - noteText = "" - mediaManager.clearImage() - popupStateDNComposer = false - }, - imageUri = mediaManager.imageUri + Dialog( + onDismissRequest = { + popupStateDNComposer = false + }, + properties = DialogProperties( + usePlatformDefaultWidth = false ) + ) { + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.55f)) + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + + DropNoteComposer( + value = noteText, + onValueChange = { noteText = it }, + + onImageClick = { + mediaManager.launchCamera() + }, + + onGalleryClick = { + mediaManager.launchGallery() + }, + + onSendClick = { + // TODO enviar nota + + noteText = "" + mediaManager.clearImage() + popupStateDNComposer = false + }, + + imageUri = mediaManager.imageUri + ) + } } } if (popupStateReview) { 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 index 30961b2..969472f 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt @@ -116,7 +116,6 @@ fun CompassWidget( .rotate(-animatedRotation) ) - // Punto central Box( modifier = Modifier .size(6.dp) From 51a5552ec2995c851654ebab46a047e68de382c2 Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Sun, 31 May 2026 18:31:18 -0500 Subject: [PATCH 19/49] [Feat]: Implement Firebase user registration and profile management Introduces a comprehensive user registration flow leveraging Firebase Authentication, Realtime Database, and Storage. Users can now sign up with expanded profile details and an optional profile picture. `UserViewModel` provides global access to the current authenticated user's data across the app. User location data is also now persisted to the Realtime Database. --- app/build.gradle.kts | 2 + .../rumbo/models/registerViewModel.kt | 144 +++++++++++ .../appnotresponding/rumbo/models/signUp.kt | 41 --- .../com/appnotresponding/rumbo/models/user.kt | 19 +- .../rumbo/navigation/navigation.kt | 17 +- .../components/organisms/auth/SignUpForm.kt | 5 +- .../rumbo/ui/screens/auth/SignUpScreen.kt | 233 +++++++++++++++--- .../rumbo/ui/screens/chat/ChatListScreen.kt | 36 +-- .../ui/screens/itinerary/ItineraryScreen.kt | 19 +- .../rumbo/ui/screens/map/MapScreen.kt | 19 +- .../rumbo/ui/screens/plan/PlanScreen.kt | 28 ++- .../ui/viewModel/userLocationViewModel.kt | 27 +- .../rumbo/ui/viewModel/userViewModel.kt | 44 ++++ gradle/libs.versions.toml | 4 + 14 files changed, 498 insertions(+), 140 deletions(-) create mode 100644 app/src/main/java/com/appnotresponding/rumbo/models/registerViewModel.kt delete mode 100644 app/src/main/java/com/appnotresponding/rumbo/models/signUp.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4752077..7fab7ae 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -74,6 +74,8 @@ dependencies { 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) testImplementation(libs.junit) 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..9f86370 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/models/registerViewModel.kt @@ -0,0 +1,144 @@ +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 + +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() + + 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(onSuccess: () -> Unit) { + val state = _registerState.value + if (!isFormValid()) return + + _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(), 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, 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?, 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) } + if (signInTask.isSuccessful) { + 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/signUp.kt b/app/src/main/java/com/appnotresponding/rumbo/models/signUp.kt deleted file mode 100644 index afec3a8..0000000 --- a/app/src/main/java/com/appnotresponding/rumbo/models/signUp.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.appnotresponding.rumbo.models - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update - -data class RegisterState( - val name: String = "", - val phone: String = "", - val email: String = "", - val password: String = "", - val country: String = "", -) - -class RegisterViewModel : ViewModel() { - private val _registerState = MutableStateFlow(RegisterState()) - fun updateName(newValue: String) { - _registerState.update { it.copy(name = newValue) } - } - - fun updatePhone(newValue: String) { - _registerState.update { it.copy(phone = newValue) } - } - - - fun updateEmail(newValue: String) { - _registerState.update { it.copy(email = newValue) } - } - - fun updatePassword(newValue: String) { - _registerState.update { it.copy(password = newValue) } - } - - fun updateCountry(newValue: String) { - _registerState.update { it.copy(country = newValue) } - } - - val registerState = _registerState.asStateFlow() - -} \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/user.kt b/app/src/main/java/com/appnotresponding/rumbo/models/user.kt index 9e2935e..419bab2 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,26 @@ + package com.appnotresponding.rumbo.models data class User( - val id: String, - val name: String, - val email: String, - val phone: String, + 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 sampleUser = User( id = "1", - name = "John Doe", + name = "John", + lastname = "Doe", email = "john.doe@mail.com", phone = "+1234567890", + latitude = 0.0, + longitude = 0.0, + altitude = 0.0, profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg" ) \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt index a3db31d..526fd99 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt @@ -19,6 +19,8 @@ import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel import androidx.lifecycle.ViewModel import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel +import com.appnotresponding.rumbo.ui.viewModel.UserViewModel + val placesViewModel: PlacesViewModel = PlacesViewModel() enum class AppScreens{ @@ -34,7 +36,10 @@ enum class AppScreens{ } @Composable -fun Navigation(locationViewModel: UserLocationViewModel = viewModel()){ +fun Navigation( + locationViewModel: UserLocationViewModel = viewModel(), + userViewModel: UserViewModel = viewModel() +){ val context = LocalContext.current val navController = rememberNavController() NavHost(navController=navController, startDestination = AppScreens.Splash.name){ @@ -48,22 +53,22 @@ fun Navigation(locationViewModel: UserLocationViewModel = viewModel()){ SignUpScreen(navController) } composable (route = AppScreens.Map.name) { - MapScreen(navController, placesViewModel, locationViewModel) + MapScreen(navController, placesViewModel, locationViewModel, userViewModel) } composable (route = AppScreens.Chat.name) { - ChatListScreen(navController) + ChatListScreen(navController, userViewModel) } composable(route = AppScreens.ChatThread.name){ ChatThreadScreen(navController) } composable(route = AppScreens.Plan.name){ - PlanScreen(navController, placesViewModel, locationViewModel) + PlanScreen(navController, placesViewModel, locationViewModel, userViewModel) } composable(route = AppScreens.Itinerary.name){ - ItineraryScreen(navController, placesViewModel) + ItineraryScreen(navController, placesViewModel, userViewModel) } composable(route = AppScreens.OnBoarding.name){ OnBoardingScreen(navController) } } -} \ 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 a62a3f9..2b9cd2d 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,3 +1,4 @@ + package com.appnotresponding.rumbo.ui.components.organisms.auth import androidx.compose.foundation.background @@ -48,7 +49,7 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme fun SignUpForm( modifier: Modifier = Modifier, //https://kotlinlang.org/docs/lambdas.html#higher-order-functions - onClick: (email: String, password: String) -> Unit = { _, _ -> }, + onClick: (name: String, phone: String, email: String, password: String) -> Unit = { _, _, _, _ -> }, ) { var fullName by remember { mutableStateOf("") } var phone by remember { mutableStateOf("") } @@ -190,7 +191,7 @@ fun SignUpForm( // Botón: Registrarse RumboButton( text = "Registrarse", - onClick = { onClick(email, password) }, + 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/screens/auth/SignUpScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt index 66bf290..304ecd3 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,64 +1,227 @@ package com.appnotresponding.rumbo.ui.screens.auth -import android.util.Log +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.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.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 com.appnotresponding.rumbo.auth +import coil3.compose.AsyncImage +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 imagePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> registerViewModel.updatePhoto(uri) } AuthTemplate { - - val scrollState = rememberScrollState() - SignUpForm( - onClick = { email, password -> - auth.createUserWithEmailAndPassword(email, password).addOnCompleteListener { - if (it.isSuccessful) { - auth.signInWithEmailAndPassword(email, password) - .addOnCompleteListener { signInTask -> - if (signInTask.isSuccessful) { - controller.navigate(AppScreens.OnBoarding.name) - } else { - Log.e( - "SignUpScreen", - "Login después del registro fallido", - signInTask.exception - ) - } - } - } else { - Log.e("SignUpScreen", "Registro fallido", it.exception) + 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 { + controller.navigate(AppScreens.OnBoarding.name) { + popUpTo(AppScreens.SignUp.name) { inclusive = true } } } - }, modifier = Modifier - .verticalScroll(scrollState) - .fillMaxSize() + }, + 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()) { + 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( + imageVector = Icons.Rounded.AddAPhoto, + 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()) } -} +} \ No newline at end of file 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 2652153..a9f69d5 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,21 +1,23 @@ package com.appnotresponding.rumbo.ui.screens.chat + import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController import com.appnotresponding.rumbo.auth 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.viewModel.UserViewModel @Composable -fun ChatListScreen(controller: NavHostController) { - val currentUser = sampleUser.copy(name = "Ana") +fun ChatListScreen(controller: NavHostController, userViewModel: UserViewModel) { + val userState by userViewModel.currentUserState.collectAsState() + val currentUser = userState ?: sampleUser.copy(name = "Cargando...") val mockChats = listOf( ChatPreviewData( @@ -69,15 +71,15 @@ fun ChatListScreen(controller: NavHostController) { } } - -@Preview( - showBackground = true, - name = "3. Pantalla Lista de Chats demostracion", - backgroundColor = 0xFF121212 -) -@Composable -private fun ChatListScreenPreview() { - RumboTheme(darkTheme = true) { - ChatListScreen(controller = rememberNavController()) - } -} +// +//@Preview( +// showBackground = true, +// name = "3. Pantalla Lista de Chats demostracion", +// backgroundColor = 0xFF121212 +//) +//@Composable +//private fun ChatListScreenPreview() { +// RumboTheme(darkTheme = true) { +// ChatListScreen(controller = rememberNavController()) +// } +//} \ No newline at end of file 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 7ab5a4b..40dd5e6 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 @@ -5,24 +5,27 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation.NavHostController import com.appnotresponding.rumbo.auth -import com.appnotresponding.rumbo.models.samplePlace import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens 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, placesViewModel: PlacesViewModel) { +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.copy(name = "Ana"), - itineraryList = state.itinerary, - controller = controller, - onProfileClick = { + user = user, itineraryList = state.itinerary, controller = controller, onProfileClick = { auth.signOut() controller.navigate(AppScreens.Splash.name) { popUpTo(controller.graph.startDestinationId) { inclusive = true } launchSingleTop = true } - }, placesViewModel) -} \ No newline at end of file + }, placesViewModel + ) +} 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 c4507f2..fc5f2cb 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,27 +1,34 @@ 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.auth import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens -import com.appnotresponding.rumbo.navigation.placesViewModel import com.appnotresponding.rumbo.ui.templates.MapTemplate 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, placesViewModel: PlacesViewModel, locationViewModel: UserLocationViewModel + controller: NavHostController, + placesViewModel: PlacesViewModel, + locationViewModel: UserLocationViewModel, + userViewModel: UserViewModel ) { + val userState by userViewModel.currentUserState.collectAsState() + val user = userState ?: sampleUser.copy(name = "Cargando...") + MapTemplate( - user = sampleUser.copy(name = "Ana"), controller = controller, onProfileClick = { + user = user, controller = controller, onProfileClick = { auth.signOut() controller.navigate(AppScreens.Splash.name) { popUpTo(controller.graph.startDestinationId) { inclusive = true } launchSingleTop = true } - }, - placesViewModel = placesViewModel, - locationViewModel = locationViewModel) + }, placesViewModel = placesViewModel, locationViewModel = locationViewModel + ) } \ 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 4c37ec3..aabd84b 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 @@ -5,28 +5,32 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.appnotresponding.rumbo.auth -import com.appnotresponding.rumbo.models.Place -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, placesViewModel: PlacesViewModel, locationViewModel: UserLocationViewModel) { +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 + userLocationState.latitude, userLocationState.longitude ) { val latitude = userLocationState.latitude @@ -36,8 +40,7 @@ fun PlanScreen(controller: NavHostController, placesViewModel: PlacesViewModel, if (latitude != 0.0 && longitude != 0.0) { searchNearbyPlaces( - latitude = latitude, - longitude = longitude, + latitude = latitude, longitude = longitude, onPlacesReceived = { places -> @@ -57,13 +60,16 @@ fun PlanScreen(controller: NavHostController, placesViewModel: PlacesViewModel, } PlanTemplate( - user = sampleUser.copy(name = "Ana"), + user = user, placesList = placesState.availablePlaces, - controller = controller, onProfileClick = { + controller = controller, + onProfileClick = { auth.signOut() controller.navigate(AppScreens.Splash.name) { popUpTo(controller.graph.startDestinationId) { inclusive = true } launchSingleTop = true } - }, placesViewModel) + }, + placesViewModel + ) } \ No newline at end of file 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 index 4b7bc01..f33f9c2 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userLocationViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userLocationViewModel.kt @@ -6,13 +6,13 @@ import android.os.Looper import android.util.Log import androidx.core.content.ContextCompat import androidx.lifecycle.AndroidViewModel -import com.google.android.gms.location.LocationRequest -import com.google.android.gms.location.LocationServices 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.Firebase 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 @@ -44,6 +44,18 @@ class UserLocationViewModel(application: Application) : AndroidViewModel(applica } 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) + 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) + } + } } } @@ -57,15 +69,12 @@ class UserLocationViewModel(application: Application) : AndroidViewModel(applica if (!permissionGranted) { permissionGranted = true if (ContextCompat.checkSelfPermission( - context, - android.Manifest.permission.ACCESS_FINE_LOCATION + context, android.Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED ) { Log.i("Informativo", "Logrado2") vel = locationClient.requestLocationUpdates( - locationRequest, - locationCallback, - Looper.getMainLooper() + locationRequest, locationCallback, Looper.getMainLooper() ) } } @@ -75,4 +84,4 @@ class UserLocationViewModel(application: Application) : AndroidViewModel(applica super.onCleared() locationClient.removeLocationUpdates(locationCallback) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt new file mode 100644 index 0000000..eac0223 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt @@ -0,0 +1,44 @@ +package com.appnotresponding.rumbo.ui.viewModel + +import androidx.lifecycle.ViewModel +import com.appnotresponding.rumbo.models.User +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.database.DataSnapshot +import com.google.firebase.database.DatabaseError +import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.ValueEventListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class UserViewModel : ViewModel() { + private val auth = FirebaseAuth.getInstance() + private val dbRef = FirebaseDatabase.getInstance().getReference("users") + + 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) + } else { + _currentUserState.value = null + } + } + } + + private fun fetchUserData(uid: String) { + dbRef.child(uid).addValueEventListener(object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val user = snapshot.getValue(User::class.java) + _currentUserState.value = user + } + + override fun onCancelled(error: DatabaseError) { + } + }) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f19de41..8b68376 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,8 @@ credentialsPlayServicesAuth = "1.6.0" googleid = "1.2.0" secretsPlugin = "2.0.1" runtime = "1.11.2" +firebaseDatabase = "22.0.1" +firebaseStorage = "22.0.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -61,6 +63,8 @@ googleid = { group = "com.google.android.libraries.identity.googleid", name = "g maps-compose = { module = "com.google.maps.android:maps-compose", 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" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From b5705e9ea1e6b05baf4b7386192715fb945ae165 Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Sun, 31 May 2026 19:04:01 -0500 Subject: [PATCH 20/49] [Feat]: Enhance secure credential storage and Firebase DB updates --- .../appnotresponding/rumbo/models/logIn.kt | 118 ++++++++++++------ .../rumbo/models/registerViewModel.kt | 59 ++++++++- .../rumbo/ui/screens/auth/SignUpScreen.kt | 7 +- .../ui/viewModel/userLocationViewModel.kt | 31 +++-- 4 files changed, 163 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt b/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt index 3a73239..0e34b6e 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt @@ -1,3 +1,4 @@ + package com.appnotresponding.rumbo.models import android.content.Context @@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.update import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat +import android.util.Log // https://kotlinlang.org/docs/sealed-classes.html sealed class AuthResult { @@ -41,36 +43,54 @@ class LoginViewModel : ViewModel() { //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(context) + val masterKey = MasterKey.Builder(appContext) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() encryptedPrefs = EncryptedSharedPreferences.create( - context, + appContext, "rumbo_secure_prefs", masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) + Log.i("LoginViewModel", "EncryptedSharedPreferences inicializado correctamente") } catch (e: Exception) { - - // Borra archivo corrupto - context.deleteSharedPreferences("rumbo_secure_prefs") - - // Reintenta crear prefs limpias - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - encryptedPrefs = EncryptedSharedPreferences.create( - context, - "rumbo_secure_prefs", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + 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 { @@ -94,11 +114,11 @@ class LoginViewModel : ViewModel() { _loginState.update { it.copy(authResult = AuthResult.Loading) } auth.signInWithEmailAndPassword(email, password).addOnSuccessListener { - saveCredentials(email, password) - _loginState.update { it.copy(authResult = AuthResult.Success) } - }.addOnFailureListener { e -> - _loginState.update { it.copy(authResult = AuthResult.Error(e.message ?: "Error")) } - } + 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 @@ -150,33 +170,55 @@ class LoginViewModel : ViewModel() { private fun firebaseSignIn(email: String, password: String) { _loginState.update { it.copy(authResult = AuthResult.Loading) } auth.signInWithEmailAndPassword(email, password).addOnSuccessListener { - _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") - ) - } + _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) { - encryptedPrefs?.edit()?.putString("email", email)?.putString("password", password)?.apply() - _loginState.update { it.copy(hasBiometricCredentials = true) } + 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 email = encryptedPrefs?.getString("email", null) - val password = encryptedPrefs?.getString("password", null) - return if (email != null && password != null) Pair(email, password) else null + 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() = encryptedPrefs?.contains("email") == true + 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()?.apply() + 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/registerViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/models/registerViewModel.kt index 9f86370..62fd9e3 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/registerViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/registerViewModel.kt @@ -1,3 +1,4 @@ + package com.appnotresponding.rumbo.models @@ -12,6 +13,10 @@ import kotlinx.coroutines.flow.update import com.google.firebase.storage.FirebaseStorage +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + data class RegisterState( val name: String = "", val lastname: String = "", @@ -30,6 +35,50 @@ class RegisterViewModel : ViewModel() { 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) } } @@ -67,10 +116,11 @@ class RegisterViewModel : ViewModel() { passwordRegex.matches(state.password) } - fun register(onSuccess: () -> Unit) { + 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) @@ -84,7 +134,7 @@ class RegisterViewModel : ViewModel() { storageRef.putFile(state.photoUri) .addOnSuccessListener { storageRef.downloadUrl.addOnSuccessListener { downloadUrl -> - saveUserToDatabase(userId, downloadUrl.toString(), onSuccess) + saveUserToDatabase(userId, downloadUrl.toString(), context, onSuccess) }.addOnFailureListener { e -> _registerState.update { it.copy(isLoading = false, firebaseError = e.message ?: "Error al obtener URL de descarga") @@ -97,7 +147,7 @@ class RegisterViewModel : ViewModel() { } } } else { - saveUserToDatabase(userId, null, onSuccess) + saveUserToDatabase(userId, null, context, onSuccess) } } else { _registerState.update { it.copy(isLoading = false, firebaseError = "Error al obtener ID de usuario") } @@ -110,7 +160,7 @@ class RegisterViewModel : ViewModel() { } } - private fun saveUserToDatabase(userId: String, photoUrl: String?, onSuccess: () -> Unit) { + private fun saveUserToDatabase(userId: String, photoUrl: String?, context: Context, onSuccess: () -> Unit) { val state = _registerState.value val newUser = User( id = userId, @@ -127,6 +177,7 @@ class RegisterViewModel : ViewModel() { .addOnCompleteListener { signInTask -> _registerState.update { it.copy(isLoading = false) } if (signInTask.isSuccessful) { + saveCredentials(state.email, state.password) onSuccess() } else { _registerState.update { 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 304ecd3..522998b 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 @@ -28,6 +28,7 @@ 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 @@ -57,6 +58,7 @@ fun SignUpScreen( 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) } @@ -83,7 +85,7 @@ fun SignUpScreen( imagePicker.launch("image/*") }, onRegister = { - registerViewModel.register { + registerViewModel.register(context) { controller.navigate(AppScreens.OnBoarding.name) { popUpTo(AppScreens.SignUp.name) { inclusive = true } } @@ -224,4 +226,5 @@ fun SignUpScreenPreview() { RumboTheme(darkTheme = true) { SignUpScreen(rememberNavController()) } -} \ No newline at end of file +} + 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 index f33f9c2..978c640 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userLocationViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userLocationViewModel.kt @@ -13,6 +13,9 @@ 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 @@ -47,14 +50,26 @@ class UserLocationViewModel(application: Application) : AndroidViewModel(applica val userId = FirebaseAuth.getInstance().currentUser?.uid if (userId != null) { val dbRef = FirebaseDatabase.getInstance().getReference("users").child(userId) - 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) - } + 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}") + } + }) } } } From 75e490521e52142d885c7a89229e208019d08e0e Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Sun, 31 May 2026 19:22:31 -0500 Subject: [PATCH 21/49] [Hotfix]:SignUp Form correction --- .../appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 522998b..b2eb611 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 @@ -109,7 +109,9 @@ fun SignUpForm( onPickPhoto: () -> Unit, onRegister: () -> Unit, ) { - Box(modifier = modifier.fillMaxWidth()) { + Box(modifier = modifier.fillMaxWidth().padding(vertical = 32.dp), + contentAlignment = Alignment.Center + ) { Box( modifier = Modifier .fillMaxWidth() From 3a4500dc34a9ba0280765d3d5c701006badc068b Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Sun, 31 May 2026 20:31:38 -0500 Subject: [PATCH 22/49] [Feat]: Display user profile images as map markers --- .../ui/components/atoms/UserProfileBubble.kt | 125 ++++++++++++ .../rumbo/ui/templates/MapTemplate.kt | 190 ++++++------------ 2 files changed, 186 insertions(+), 129 deletions(-) create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt 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..ae5b1f5 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt @@ -0,0 +1,125 @@ +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.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.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), + imageVector = Icons.Rounded.Person, + 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/templates/MapTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt index 0d1610b..e8a4165 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,9 +1,5 @@ package com.appnotresponding.rumbo.ui.templates -import android.content.pm.PackageManager -import android.hardware.Sensor -import android.hardware.SensorManager -import android.os.Looper import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -16,15 +12,12 @@ 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.material3.Button -import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -36,28 +29,29 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview 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.core.content.ContextCompat -import androidx.lifecycle.ViewModel 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.geocoder import com.appnotresponding.rumbo.isDarkTheme 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.UserProfileBubble import com.appnotresponding.rumbo.ui.components.molecules.map.CancelRoute import com.appnotresponding.rumbo.ui.components.molecules.map.LocateMe import com.appnotresponding.rumbo.ui.components.molecules.map.WriteDropNote @@ -65,9 +59,7 @@ 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.utils.SensorOverlay -import com.appnotresponding.rumbo.ui.utils.createLocationCallback import com.appnotresponding.rumbo.ui.utils.createLocationRequest import com.appnotresponding.rumbo.ui.utils.rememberLocationManager import com.appnotresponding.rumbo.ui.utils.rememberMediaHardwareManager @@ -78,11 +70,7 @@ 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.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 import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.model.CameraPosition @@ -92,13 +80,10 @@ 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 kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import org.osmdroid.util.GeoPoint @@ -123,6 +108,19 @@ fun MapTemplate(user: User, val locationState = rememberLocationManager() val mediaManager = rememberMediaHardwareManager() var noteText by remember { mutableStateOf("") } + 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) } @@ -131,7 +129,7 @@ fun MapTemplate(user: User, } var currentMapStyle by remember { mutableIntStateOf(MapColorScheme.FOLLOW_SYSTEM) } val mapId = - stringResource(R.string.map_id) // Controla el estilo de color del mapa (claro, oscuro o seguir el sistema) y se actualiza dinámicamente según los cambios en el sensor de luz ambiental + stringResource(R.string.map_id) var permission = rememberPermissionState(locationPermission) var showButton by remember { mutableStateOf(false) } @@ -149,36 +147,38 @@ fun MapTemplate(user: User, if (permission.status.isGranted) { if (!locationViewModel.permissionGranted) locationViewModel.updateVel() } + LaunchedEffect( userLocationState.latitude, - userLocationState.longitude + userLocationState.longitude, + state.centerInUserFirstTime ) { Log.d("RECOMPOSE", "Enntrando en launch") - viewModel.updateUserMarker( - userLocationState.latitude, - userLocationState.longitude - ) + val tieneUbicacionReal = userLocationState.latitude != 0.0 || userLocationState.longitude != 0.0 - if (state.centerInUserFirstTime && (placesState.selectedPlace == null)) { - cameraPositionState.position = - CameraPosition.fromLatLngZoom( - LatLng( - userLocationState.latitude, - userLocationState.longitude - ), 18f + 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 (state.centerInUserFirstTime && (placesState.selectedPlace != null)) { - cameraPositionState.position = - CameraPosition.fromLatLngZoom( + viewModel.updateCenterInUserFirstTime() + } else if (tieneUbicacionReal && placesState.selectedPlace != null) { + cameraPositionState.position = CameraPosition.fromLatLngZoom( LatLng( placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude ), 14f ) - viewModel.updateCenterInUserFirstTime() + viewModel.updateCenterInUserFirstTime() + } + // Si aún no hay ubicación real, la cámara se queda en Bogotá (valor inicial) } - if (placesState.selectedPlace != null) { + + if (tieneUbicacionReal && placesState.selectedPlace != null) { val startPoint = GeoPoint(userLocationState.latitude, userLocationState.longitude) val destination = GeoPoint( placesState.selectedPlace!!.latitude, @@ -269,7 +269,6 @@ fun MapTemplate(user: User, GoogleMapOptions().apply { mapId(mapId) mapType(GoogleMap.MAP_TYPE_NORMAL) - } }) { // https://medium.com/@ferobregon03/compose-multiplatform-displaying-and-updating-geojson-on-a-mapbox-96f025d8024a @@ -278,10 +277,23 @@ fun MapTemplate(user: User, MapEffect(currentMapStyle) { googleMap -> googleMap.mapColorScheme = currentMapStyle } - Marker( - state = rememberUpdatedMarkerState(LatLng(userLocationState.latitude, userLocationState.longitude)), - title = "User" - ) + MarkerComposable( + keys = arrayOf( + userLocationState.latitude, + userLocationState.longitude, + markerKey, + profileBitmap != null + ), + state = rememberUpdatedMarkerState( + LatLng(userLocationState.latitude, userLocationState.longitude) + ), + title = user.name + ) { + UserProfileBubble( + user = user, + preloadedBitmap = profileBitmap + ) + } Marker( state = rememberUpdatedMarkerState(state.additionalMarker.position), title = state.additionalMarker.title, @@ -394,84 +406,4 @@ fun MapTemplate(user: User, PlacePreviewCard(place = samplePlace, reviews = listOf(sampleReview)) } } -} - -/** -@OptIn(ExperimentalPermissionsApi::class) -@Composable -fun MapTemplate( user: User, - controller: NavHostController, - onProfileClick: () -> Unit = {}, placesViewModel: PlacesViewModel){ - 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){ - Mapa( - user, - controller, - onProfileClick, placesViewModel = placesViewModel) - }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, - color = Color.Red, - textAlign = TextAlign.Center, - fontSize = 20.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, - color = Color.Red, - textAlign = TextAlign.Center, - fontSize = 20.sp) - } - } - } -} -*/ - -/** -@Preview(showBackground = true, name = "PlacePreviewCard - Light") -@Composable -private fun MapTemplateLightPreview() { - RumboTheme(darkTheme = true) { - MapTemplate( - sampleUser, controller = rememberNavController() - ) - } -} - -@Preview(showBackground = true, backgroundColor = 0xFF1E1E1E, name = "PlacePreviewCard - Dark") -@Composable -private fun MapTemplateDarkPreview() { - RumboTheme(darkTheme = false) { - MapTemplate( - sampleUser, controller = rememberNavController() - ) - } -} -*/ \ No newline at end of file +} \ No newline at end of file From 52191cbe9a60f29e88843b2e2ee8f424b996e79e Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Sun, 31 May 2026 21:34:36 -0500 Subject: [PATCH 23/49] [Feat]: Enhance UI/UX with dynamic opening hours and map refinements Implement robust dynamic opening hours calculation and display for places. Introduce profile picture selection during user sign-up. Improve map user experience with a redesigned compass, consistent avatar markers, and themed polyline routes. Standardize image loading and error handling across various components, and refine general UI spacing. --- .../rumbo/ui/components/atoms/Avatar.kt | 18 +- .../molecules/itinerary/ItineraryItemCard.kt | 171 ++++++++++++-- .../components/molecules/map/PlaceReview.kt | 5 +- .../components/molecules/plan/PlanItemCard.kt | 12 +- .../components/organisms/auth/SignUpForm.kt | 41 +++- .../ui/components/organisms/common/TopBar.kt | 7 +- .../organisms/itinerary/ItineraryOverview.kt | 36 +-- .../components/organisms/plan/PlanPOIList.kt | 6 + .../rumbo/ui/templates/ItineraryTemplate.kt | 5 +- .../rumbo/ui/templates/MapTemplate.kt | 123 +++++------ .../rumbo/ui/templates/PlanTemplate.kt | 35 ++- .../rumbo/ui/utils/CompassManager.kt | 78 ++++--- .../rumbo/ui/utils/SensorOverlay.kt | 209 ++++++------------ 13 files changed, 446 insertions(+), 300 deletions(-) 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..be3fa51 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 @@ -14,6 +14,7 @@ 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 +28,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,11 +63,21 @@ fun Avatar( borderColor: Color = MaterialTheme.colorScheme.outline, isOnline: Boolean = false ) { + val context = LocalContext.current val pfp = user?.profilePictureUrl 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 = remember(pfp) { + pfp?.let { + ImageRequest.Builder(context) + .data(it) + .allowHardware(false) + .build() + } + } + // Tamaño del indicador online proporcional al tamaño del avatar val indicatorSize = when (size) { AvatarSize.Small -> 10.dp @@ -101,9 +115,9 @@ fun Avatar( ) { //Verificar si hay foto de perfil when { - pfp != null -> { + imageRequest != null -> { SubcomposeAsyncImage( - model = pfp, + model = imageRequest, contentDescription = contentDescription, contentScale = ContentScale.Crop, modifier = Modifier 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 42f489f..79613a6 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 @@ -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 @@ -18,18 +19,21 @@ 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. @@ -53,11 +57,13 @@ fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel, controller: Na 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 ) }) } @@ -73,43 +79,164 @@ fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel, controller: Na color = MaterialTheme.colorScheme.onBackground ) Text( - text = p.openHours.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground - ) - Text( - text = p.price ?: "No hay información", + text = formatOpenHours(p.openHours), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground ) RumboButton( - text = "Iniciar Desplazamiento", - onClick = { + text = "Iniciar Desplazamiento", onClick = { placesViewModel.selectForNavigation(p) controller.navigate(AppScreens.Map.name) - }, - style = RumboButtonStyle.Secondary, - icon = painterResource(R.drawable.ic_map) + }, 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 + */ \ No newline at end of file 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 4dce223..76bbdbe 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 @@ -56,11 +57,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 ) }) } 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 758c983..b267557 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 @@ -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 @@ -56,12 +57,14 @@ fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel) { 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) + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSecondaryContainer), + contentScale = ContentScale.Crop ) }) } @@ -77,15 +80,16 @@ fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel) { color = MaterialTheme.colorScheme.onBackground ) Text( - text = p.description ?: "No hay información", + text = p.description ?: "No hay descripción", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground ) - Text( + /*Text( text = p.price ?: "No hay información", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground - ) + )*/ + RumboButton( text = msg, onClick = { icon = R.drawable.ic_check 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 2b9cd2d..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 @@ -2,22 +2,28 @@ 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 @@ -28,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 @@ -51,6 +59,8 @@ fun SignUpForm( //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("") } @@ -77,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, 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 1f754f5..35112ac 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 @@ -34,6 +34,7 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme @Composable fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) { val bottomRoundedShape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) + val displayName = u.name.replace(Regex(" +$"), "") Surface( shape = bottomRoundedShape, color = MaterialTheme.colorScheme.surfaceContainerLow, ) { @@ -56,7 +57,7 @@ fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) { verticalAlignment = Alignment.CenterVertically ) { Text( - text = "¡Hola, ${u.name}!", + text = "¡Hola, ${displayName}!", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface ) @@ -79,6 +80,7 @@ fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) { @Composable fun ChatTopBar(u: User, activity: String? = null) { val bottomRoundedShape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) + val displayName = u.name.replace(Regex(" +$"), "") Surface( shape = bottomRoundedShape, color = MaterialTheme.colorScheme.surfaceContainerLow, ) { @@ -93,7 +95,7 @@ fun ChatTopBar(u: User, activity: String? = null) { Column { Text( - text = u.name, + text = displayName, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(start = 8.dp), color = MaterialTheme.colorScheme.onSurface @@ -142,4 +144,3 @@ private fun ChatTopBarDarkPreview() { ChatTopBar(u = sampleUser, activity = "Rumbo al Museo Nacional") } } - 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 35efaa0..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,18 +1,17 @@ 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 /** @@ -25,7 +24,11 @@ import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel */ @Composable -fun ItineraryOverview(itineraryList: List, placesViewModel: PlacesViewModel, controller: NavHostController) { +fun ItineraryOverview( + itineraryList: List, + placesViewModel: PlacesViewModel, + controller: NavHostController +) { LazyColumn( modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) @@ -33,6 +36,9 @@ fun ItineraryOverview(itineraryList: List, placesViewModel: PlacesViewMod items(itineraryList) { place -> ItineraryItemCard(p = place, placesViewModel, controller) } + item { + Spacer(modifier = Modifier.height(96.dp)) + } } } @@ -40,20 +46,20 @@ fun ItineraryOverview(itineraryList: List, placesViewModel: PlacesViewMod @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) - ) - } +RumboTheme(darkTheme = true) { +ItineraryOverview( +itineraryList = listOf(samplePlace, samplePlace, samplePlace) +) +} } - */ \ No newline at end of file + */ \ 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 940c735..e3f142f 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 @@ -33,6 +36,9 @@ fun PlanPOIList(places: List, placesViewModel: PlacesViewModel) { items(places) { place -> PlanItemCard(p = place, placesViewModel) } + item { + Spacer(modifier = Modifier.height(96.dp)) + } } } 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 2f2e01a..c381336 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,9 +1,11 @@ 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 @@ -55,7 +57,8 @@ fun ItineraryTemplate( DayHeader(title = "Así Se Ve Tu Día") Spacer(modifier = Modifier.height(16.dp)) - ItineraryOverview(itineraryList = itineraryList, placesViewModel, controller) + Box(modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp)) { + ItineraryOverview(itineraryList = itineraryList, placesViewModel, controller)} } } } 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 e8a4165..9a7bb46 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 @@ -2,6 +2,7 @@ package com.appnotresponding.rumbo.ui.templates import android.util.Log 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 @@ -13,7 +14,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -51,6 +54,8 @@ import com.appnotresponding.rumbo.models.User import com.appnotresponding.rumbo.models.samplePlace import com.appnotresponding.rumbo.models.sampleReview import com.appnotresponding.rumbo.roadManager +import com.appnotresponding.rumbo.ui.components.atoms.Avatar +import com.appnotresponding.rumbo.ui.components.atoms.AvatarSize import com.appnotresponding.rumbo.ui.components.atoms.UserProfileBubble import com.appnotresponding.rumbo.ui.components.molecules.map.CancelRoute import com.appnotresponding.rumbo.ui.components.molecules.map.LocateMe @@ -88,17 +93,22 @@ import org.osmdroid.util.GeoPoint val locationPermission = android.Manifest.permission.ACCESS_FINE_LOCATION -var locationRequest : LocationRequest = createLocationRequest() +var locationRequest: LocationRequest = createLocationRequest() @OptIn(ExperimentalPermissionsApi::class) @Composable -fun MapTemplate(user: User, - controller: NavHostController, - onProfileClick: () -> Unit = {}, - viewModel: MapViewModel = viewModel(), placesViewModel: PlacesViewModel, locationViewModel: UserLocationViewModel +fun MapTemplate( + user: User, + controller: NavHostController, + onProfileClick: () -> Unit = {}, + viewModel: MapViewModel = viewModel(), + placesViewModel: PlacesViewModel, + locationViewModel: UserLocationViewModel ) { Log.d("RECOMPOSE", "MapTemplate recomposed") - var context = LocalContext.current + + val context = LocalContext.current + val state by viewModel.uiState.collectAsState() val userLocationState by locationViewModel.uiState.collectAsState() val placesState by placesViewModel.uiState.collectAsState() @@ -134,10 +144,10 @@ fun MapTemplate(user: User, var permission = rememberPermissionState(locationPermission) var showButton by remember { mutableStateOf(false) } SideEffect { - if(!permission.status.isGranted){ - if(permission.status.shouldShowRationale){ + if (!permission.status.isGranted) { + if (permission.status.shouldShowRationale) { showButton = true - }else { + } else { showButton = false permission.launchPermissionRequest() } @@ -181,8 +191,7 @@ fun MapTemplate(user: User, if (tieneUbicacionReal && placesState.selectedPlace != null) { val startPoint = GeoPoint(userLocationState.latitude, userLocationState.longitude) val destination = GeoPoint( - placesState.selectedPlace!!.latitude, - placesState.selectedPlace!!.longitude + placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude ) val points = arrayListOf(startPoint, destination) val road = roadManager.getRoad(points) @@ -196,13 +205,11 @@ fun MapTemplate(user: User, if (placesState.selectedPlace != null) { viewModel.updateAdditionalMarker( LatLng( - placesState.selectedPlace!!.latitude, - placesState.selectedPlace!!.longitude + placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude ), placesState.selectedPlace!!.name ) - val startPoint = GeoPoint( - placesState.selectedPlace!!.latitude, - placesState.selectedPlace!!.longitude + GeoPoint( + placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude ) } } @@ -218,13 +225,12 @@ fun MapTemplate(user: User, floatingActionButton = { Column( modifier = Modifier - .height(150.dp) .width(45.dp), - verticalArrangement = Arrangement.spacedBy(30.dp) + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom) ) { - if(permission.status.isGranted) { - if(placesState.selectedPlace!=null){ - CancelRoute{ + if (permission.status.isGranted) { + if (placesState.selectedPlace != null) { + CancelRoute { placesViewModel.clearForNavigation() viewModel.updateRoutePoints(emptyList()) viewModel.cancelAdditionalMarkerVisibility() @@ -250,7 +256,7 @@ fun MapTemplate(user: User, } }, bottomBar = { Nav(controller) }) { paddingValues -> - if(permission.status.isGranted) { + if (permission.status.isGranted) { Box( modifier = Modifier .fillMaxSize() @@ -278,21 +284,14 @@ fun MapTemplate(user: User, googleMap.mapColorScheme = currentMapStyle } MarkerComposable( - keys = arrayOf( - userLocationState.latitude, - userLocationState.longitude, - markerKey, - profileBitmap != null - ), state = rememberUpdatedMarkerState( - LatLng(userLocationState.latitude, userLocationState.longitude) - ), - title = user.name - ) { - UserProfileBubble( - user = user, - preloadedBitmap = profileBitmap - ) + LatLng( + userLocationState.latitude, + userLocationState.longitude + ) + ), title = "${user.name} (Tú)" + ){ + Avatar(user = user, modifier = Modifier.border(1.dp, Color.White, CircleShape)) } Marker( state = rememberUpdatedMarkerState(state.additionalMarker.position), @@ -301,53 +300,52 @@ fun MapTemplate(user: User, ) if (state.routePoints.isNotEmpty()) { Polyline( - points = state.routePoints, - color = Color.Blue, - width = 10f + points = state.routePoints, color = MaterialTheme.colorScheme.primary, width = 10f ) } if (state.userRouteVisible) { Polyline( - points = state.userRoutePoints, - color = Color.Blue, - width = 10f + points = state.userRoutePoints, color = MaterialTheme.colorScheme.tertiary, width = 10f ) } } SensorOverlay( modifier = Modifier - .align(Alignment.TopStart) + .align(Alignment.TopEnd) + .padding(top = 64.dp) .padding(16.dp) ) } - } else{ + } else { Column( - modifier = Modifier.fillMaxSize().padding(15.dp), + 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." + 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) + Text( + message, textAlign = TextAlign.Center, fontSize = 15.sp + ) Spacer(modifier = Modifier.height(25.dp)) Button( - modifier = Modifier.fillMaxWidth(), - onClick = { + modifier = Modifier.fillMaxWidth(), onClick = { permission.launchPermissionRequest() }) { Text("Solicitar Permiso") } - } - else{ + } else { Spacer(modifier = Modifier.height(25.dp)) - Text(message, - textAlign = TextAlign.Center, - fontSize = 15.sp) + Text( + message, textAlign = TextAlign.Center, fontSize = 15.sp + ) } } } @@ -356,8 +354,7 @@ fun MapTemplate(user: User, Dialog( onDismissRequest = { popupStateDNComposer = false - }, - properties = DialogProperties( + }, properties = DialogProperties( usePlatformDefaultWidth = false ) ) { @@ -366,13 +363,11 @@ fun MapTemplate(user: User, modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.55f)) - .padding(20.dp), - contentAlignment = Alignment.Center + .padding(20.dp), contentAlignment = Alignment.Center ) { DropNoteComposer( - value = noteText, - onValueChange = { noteText = it }, + value = noteText, onValueChange = { noteText = it }, onImageClick = { mediaManager.launchCamera() 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 7d7af6a..24fc2ad 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,14 @@ 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 /** @@ -65,24 +60,24 @@ fun PlanTemplate( @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() - ) - } +RumboTheme(darkTheme = true) { +PlanTemplate( +user = sampleUser, +placesList = listOf(samplePlace, samplePlace, samplePlace), +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/utils/CompassManager.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/CompassManager.kt index fc05ab8..1ec5c16 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/CompassManager.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/CompassManager.kt @@ -14,31 +14,21 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext data class CompassState( - val degrees: Float, val direction: String + val degrees: Float ) - -// grados a cardinalidad -private fun getCardinalDirection(degrees: Float): String { - return when { - degrees < 22.5f || degrees >= 337.5f -> "Norte ↑" - degrees < 67.5f -> "Noreste ↗" - degrees < 112.5f -> "Este →" - degrees < 157.5f -> "Sureste ↘" - degrees < 202.5f -> "Sur ↓" - degrees < 247.5f -> "Suroeste ↙" - degrees < 292.5f -> "Oeste ←" - else -> "Noroeste ↖" - } -} - @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) } @@ -48,13 +38,29 @@ fun rememberCompassManager(context: Context = LocalContext.current): CompassStat } DisposableEffect(Unit) { - // El magnetometro necesita el acelerometro para calcular la orientacion correctamente 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] @@ -68,24 +74,31 @@ fun rememberCompassManager(context: Context = LocalContext.current): CompassStat } } - val rotationMatrix = FloatArray(9) - val inclinationMatrix = FloatArray(9) - val success = SensorManager.getRotationMatrix( - rotationMatrix, inclinationMatrix, gravity, geomagnetic - ) - - if (success) { - val orientation = FloatArray(3) - SensorManager.getOrientation(rotationMatrix, orientation) - // radianes a grados y se da el valor entre 0 y 360 - val azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat() - degrees = (azimuth + 360f) % 360f + 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 ) @@ -99,6 +112,11 @@ fun rememberCompassManager(context: Context = LocalContext.current): CompassStat } return CompassState( - degrees = degrees, direction = getCardinalDirection(degrees) + 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/SensorOverlay.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt index 969472f..6c59833 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt @@ -6,9 +6,6 @@ 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.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -22,10 +19,12 @@ 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.graphics.Color +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) { @@ -33,19 +32,13 @@ fun SensorOverlay(modifier: Modifier = Modifier) { val compassState = rememberCompassManager() Column( - modifier = modifier - .background( - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f), - shape = RoundedCornerShape(12.dp) - ) - .padding(horizontal = 14.dp, vertical = 10.dp), + modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp), horizontalAlignment = Alignment.Start ) { // Indicador de movimiento con el acelerometro CompassWidget( degrees = compassState.degrees, - direction = compassState.direction ) } } @@ -53,7 +46,6 @@ fun SensorOverlay(modifier: Modifier = Modifier) { @Composable fun CompassWidget( degrees: Float, - direction: String, modifier: Modifier = Modifier ) { @@ -61,145 +53,88 @@ fun CompassWidget( targetValue = degrees, label = "CompassRotation" ) - Spacer(modifier = Modifier.height(30.dp)) - Box( - modifier = modifier - .size(82.dp) - .background( - MaterialTheme.colorScheme.surface.copy(alpha = 0.88f), - CircleShape - ) - .border( - 1.5.dp, - MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), - CircleShape - ), - contentAlignment = Alignment.Center + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - - // Norte - Text( - text = "N", - modifier = Modifier.offset(y = (-28).dp), - color = Color.Red, - style = MaterialTheme.typography.labelSmall - ) - - // Sur - Text( - text = "S", - modifier = Modifier.offset(y = (28).dp), - style = MaterialTheme.typography.labelSmall - ) - - // Este - Text( - text = "E", - modifier = Modifier.offset(x = (28).dp), - style = MaterialTheme.typography.labelSmall - ) - - // Oeste - Text( - text = "O", - modifier = Modifier.offset(x = (-28).dp), - style = MaterialTheme.typography.labelSmall - ) - - // Flecha - Icon( - imageVector = Icons.Default.Navigation, - contentDescription = "Compass Arrow", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .size(36.dp) - .rotate(-animatedRotation) - ) - Box( modifier = Modifier - .size(6.dp) + .shadow(4.dp, CircleShape) + .size(48.dp) .background( - MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.9f), CircleShape ) - ) - } -} - -/** -@Composable -fun CompassWidget( - degrees: Float, - direction: String, - modifier: Modifier = Modifier -) { - - val animatedRotation by animateFloatAsState( - targetValue = degrees, - label = "CompassRotation" - ) + .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 + ) - Box( - modifier = modifier - .size(140.dp) - .background( - MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), - CircleShape + // Sur + Text( + text = "S", + modifier = Modifier.offset(y = (16).dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - .border( - 2.dp, - MaterialTheme.colorScheme.primary.copy(alpha = 0.4f), - CircleShape - ), - contentAlignment = Alignment.Center - ) { - // Rosa de los vientos - Text( - text = "N", - modifier = Modifier.offset(y = (-50).dp), - color = Color.Red, - style = MaterialTheme.typography.titleMedium - ) + // Este + Text( + text = "E", + modifier = Modifier.offset(x = (16).dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - Text( - text = "S", - modifier = Modifier.offset(y = (50).dp), - style = MaterialTheme.typography.bodyMedium - ) + // Oeste + Text( + text = "O", + modifier = Modifier.offset(x = (-16).dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - Text( - text = "E", - modifier = Modifier.offset(x = (50).dp), - style = MaterialTheme.typography.bodyMedium - ) + // Flecha + Icon( + imageVector = Icons.Default.Navigation, + contentDescription = "Compass Arrow", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(16.dp) + .rotate(-animatedRotation) + ) - Text( - text = "O", - modifier = Modifier.offset(x = (-50).dp), - style = MaterialTheme.typography.bodyMedium - ) + Box( + modifier = Modifier + .size(4.dp) + .background( + MaterialTheme.colorScheme.primary, + CircleShape + ) + ) + } - // Flecha - Icon( - Icons.Default.Navigation, - contentDescription = "Compass Arrow", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .size(70.dp) - .rotate(-animatedRotation) - ) + } +} - // Centro - Box( - modifier = Modifier - .size(12.dp) - .background( - MaterialTheme.colorScheme.primary, - CircleShape - ) +@Preview(showBackground = true) +@Composable +fun CompassWidgetPreview() { + RumboTheme() { + CompassWidget( + degrees = 45f ) } } - */ \ No newline at end of file From 2f15926ca2c19d8ba771dd143d430ad0e306b042 Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Sun, 31 May 2026 22:23:20 -0500 Subject: [PATCH 24/49] [Feat]: Implement dynamic add/remove functionality for itinerary items Enhances the `PlanItemCard` to allow users to dynamically add or remove places from their itinerary. The button's text and icon now update in real-time, reflecting whether a place is currently part of the itinerary. This improves the user experience by providing clear feedback and direct control over itinerary management from the place cards. The `PlacesViewModel` now includes a `removeFromItinerary` method to support this functionality, and the UI observes the ViewModel's state. --- .../components/molecules/plan/PlanItemCard.kt | 36 ++++++++++--------- .../rumbo/ui/viewModel/placesViewModel.kt | 7 ++++ app/src/main/res/drawable/ic_minus.xml | 10 ++++++ 3 files changed, 36 insertions(+), 17 deletions(-) create mode 100644 app/src/main/res/drawable/ic_minus.xml 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 b267557..4c6b452 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 @@ -13,21 +13,20 @@ import androidx.compose.foundation.layout.padding 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 coil3.compose.SubcomposeAsyncImage 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.RumboButtonStyle -import com.appnotresponding.rumbo.ui.theme.RumboTheme import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel /** @@ -41,8 +40,10 @@ import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel @Composable fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel) { - 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( @@ -83,8 +84,7 @@ fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel) { text = p.description ?: "No hay descripción", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground - ) - /*Text( + )/*Text( text = p.price ?: "No hay información", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onBackground @@ -92,9 +92,11 @@ fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel) { RumboButton( text = msg, onClick = { - icon = R.drawable.ic_check - msg = "Añadido al Itinerario" - placesViewModel.addToItinerary(p) + if (isInItinerary) { + placesViewModel.removeFromItinerary(p) + } else { + placesViewModel.addToItinerary(p) + } }, style = RumboButtonStyle.Secondary, icon = painterResource(icon) ) } @@ -106,16 +108,16 @@ fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel) { @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) - } +RumboTheme(darkTheme = true) { +PlanItemCard(p = samplePlace) +} } - */ \ No newline at end of file + */ \ 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 index f0d2ec5..c7742b9 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt @@ -23,6 +23,13 @@ class PlacesViewModel : ViewModel() { } } + fun removeFromItinerary(place: Place) { + val current = _uiState.value.itinerary + if (current.any { it.id == place.id }) { + _uiState.update { it.copy(itinerary = current.filterNot { it.id == place.id }) } + } + } + fun selectForNavigation(place: Place) { _uiState.update { it.copy(selectedPlace = place) } } 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..88184ed --- /dev/null +++ b/app/src/main/res/drawable/ic_minus.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file From 910fc6b9c608c23a71e35f8c269ba14b2680e191 Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Sun, 31 May 2026 22:32:09 -0500 Subject: [PATCH 25/49] [Feat]: Introduce real-time drop notes on map with Firebase persistence --- .../appnotresponding/rumbo/models/dropNote.kt | 36 ++++-- .../components/organisms/map/ViewDropnote.kt | 20 +++ .../rumbo/ui/templates/MapTemplate.kt | 102 ++++++++++++--- .../rumbo/ui/viewModel/mapViewModel.kt | 122 +++++++++++++++++- 4 files changed, 247 insertions(+), 33 deletions(-) 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..08d5212 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,32 @@ 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 creatorName: String = "", + val creatorAvatarUrl: String? = null, + 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 user: User + get() = User( + id = creatorId, + name = creatorName, + profilePictureUrl = creatorAvatarUrl + ) + val authorAvatarUrl: String? + get() = creatorAvatarUrl +} val sampleDropNote = DropNote( id = "1", - user = sampleUser, + creatorId = sampleUser.id, + creatorName = sampleUser.name, + creatorAvatarUrl = sampleUser.profilePictureUrl, public = true, content = "Hello! This is a sample drop note.", imageUrl = null, 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..6f142cc 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,9 +13,12 @@ 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 @@ -25,6 +30,7 @@ fun ViewDropNote( modifier: Modifier = Modifier, user: User, content: String = "", + imageUrl: String? = null, isPrivate: Boolean = false, ) { Surface( @@ -44,6 +50,7 @@ fun ViewDropNote( user = user, size = AvatarSize.Medium ) Column( + modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp) ) { Row( @@ -63,6 +70,19 @@ fun ViewDropNote( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) + + if (!imageUrl.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + AsyncImage( + model = imageUrl, + contentDescription = "Imagen de la DropNote", + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clip(MaterialTheme.shapes.medium), + contentScale = ContentScale.Crop + ) + } } } } 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 9a7bb46..ec50820 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 @@ -64,6 +64,9 @@ 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.models.DropNote +import com.appnotresponding.rumbo.ui.components.molecules.map.DropNoteBubble +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 @@ -115,6 +118,8 @@ fun MapTemplate( var popupStateDNComposer by remember { mutableStateOf(false) } var popupStateReview by remember { mutableStateOf(false) } + var popupStateViewDN by remember { mutableStateOf(false) } + var selectedDropNote by remember { mutableStateOf(null) } val locationState = rememberLocationManager() val mediaManager = rememberMediaHardwareManager() var noteText by remember { mutableStateOf("") } @@ -309,6 +314,21 @@ fun MapTemplate( points = state.userRoutePoints, color = MaterialTheme.colorScheme.tertiary, width = 10f ) } + + state.dropNotes.forEach { note -> + val position = LatLng(note.latitude, note.longitude) + MarkerComposable( + state = rememberUpdatedMarkerState(position), + title = "DropNote de ${note.creatorName}", + onClick = { + selectedDropNote = note + popupStateViewDN = true + true + } + ) { + DropNoteBubble(d = note) + } + } } SensorOverlay( modifier = Modifier @@ -353,7 +373,9 @@ fun MapTemplate( if (popupStateDNComposer) { Dialog( onDismissRequest = { - popupStateDNComposer = false + if (!state.isUploadingNote) { + popupStateDNComposer = false + } }, properties = DialogProperties( usePlatformDefaultWidth = false ) @@ -365,28 +387,48 @@ fun MapTemplate( .background(Color.Black.copy(alpha = 0.55f)) .padding(20.dp), contentAlignment = Alignment.Center ) { + if (state.isUploadingNote) { + androidx.compose.material3.CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary + ) + } else { + DropNoteComposer( + value = noteText, onValueChange = { noteText = it }, - DropNoteComposer( - value = noteText, onValueChange = { noteText = it }, - - onImageClick = { - mediaManager.launchCamera() - }, - - onGalleryClick = { - mediaManager.launchGallery() - }, + onImageClick = { + mediaManager.launchCamera() + }, - onSendClick = { - // TODO enviar nota + onGalleryClick = { + mediaManager.launchGallery() + }, - noteText = "" - mediaManager.clearImage() - popupStateDNComposer = false - }, + 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 + + viewModel.uploadAndSaveDropNote( + content = noteText, + imageUri = mediaManager.imageUri, + latitude = lat, + longitude = lng, + creatorId = user.id, + creatorName = "${user.name} ${user.lastname}", + creatorAvatarUrl = user.profilePictureUrl, + onSuccess = { + noteText = "" + mediaManager.clearImage() + popupStateDNComposer = false + } + ) + } + }, - imageUri = mediaManager.imageUri - ) + imageUri = mediaManager.imageUri + ) + } } } } @@ -401,4 +443,26 @@ fun MapTemplate( PlacePreviewCard(place = samplePlace, reviews = listOf(sampleReview)) } } + if (popupStateViewDN && selectedDropNote != null) { + Dialog( + onDismissRequest = { + popupStateViewDN = false + selectedDropNote = null + } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color.Transparent) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + ViewDropNote( + user = selectedDropNote!!.user, + content = selectedDropNote!!.content, + imageUrl = selectedDropNote!!.imageUrl + ) + } + } + } } \ No newline at end of file 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 index 4c391d3..98da2d6 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt @@ -1,7 +1,14 @@ package com.appnotresponding.rumbo.ui.viewModel +import android.net.Uri import androidx.lifecycle.ViewModel +import com.appnotresponding.rumbo.models.DropNote import com.google.android.gms.maps.model.LatLng +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 @@ -18,7 +25,10 @@ data class MapState( val userRouteVisible: Boolean = false, val place: String = "", val centerInUserFirstTime: Boolean = true, - val lastSafeLatLng: LatLng = LatLng(0.0, 0.0) + val lastSafeLatLng: LatLng = LatLng(0.0, 0.0), + val dropNotes: List = emptyList(), + val isUploadingNote: Boolean = false, + val noteUploadError: String = "" ) class MapViewModel: ViewModel() { @@ -26,6 +36,116 @@ class MapViewModel: ViewModel() { private val _uiState = MutableStateFlow(MapState()) val uiState: StateFlow = _uiState.asStateFlow() + private val dbDropNotes = FirebaseDatabase.getInstance().getReference("dropNotes") + + init { + fetchDropNotes() + } + + private fun fetchDropNotes() { + dbDropNotes.addValueEventListener(object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val notes = mutableListOf() + for (child in snapshot.children) { + val note = child.getValue(DropNote::class.java) + if (note != null) { + notes.add(note) + } + } + _uiState.update { it.copy(dropNotes = notes) } + } + + override fun onCancelled(error: DatabaseError) { + // Manejo de error + } + }) + } + + fun uploadAndSaveDropNote( + content: String, + imageUri: Uri?, + latitude: Double, + longitude: Double, + creatorId: String, + creatorName: String, + creatorAvatarUrl: 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, + creatorName = creatorName, + creatorAvatarUrl = creatorAvatarUrl, + onSuccess = onSuccess + ) + }.addOnFailureListener { e -> + _uiState.update { it.copy(isUploadingNote = false, noteUploadError = e.message ?: "Error al obtener URL de descarga") } + } + } + .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, + creatorName = creatorName, + creatorAvatarUrl = creatorAvatarUrl, + onSuccess = onSuccess + ) + } + } + + private fun saveDropNoteMetadata( + noteId: String, + content: String, + imageUrl: String?, + latitude: Double, + longitude: Double, + creatorId: String, + creatorName: String, + creatorAvatarUrl: String?, + onSuccess: () -> Unit + ) { + val dropNote = DropNote( + id = noteId, + creatorId = creatorId, + creatorName = creatorName, + creatorAvatarUrl = creatorAvatarUrl, + 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 updatePlace(place: String) { _uiState.update { it.copy(place = place) } } From 1dcb2b1d48b5b09374303f9e6c4021031fab22d8 Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Sun, 31 May 2026 23:14:45 -0500 Subject: [PATCH 26/49] [Feat]: Refine UI components for improved avatar display and map elements Enhance avatar reliability with robust image loading, better error handling, and consistent initial display logic. Correct the compass widget's rotation to reflect actual bearing. Standardize the visual size of drop note bubbles on the map. --- .../rumbo/ui/components/atoms/Avatar.kt | 49 +++++++++++++++---- .../rumbo/ui/templates/MapTemplate.kt | 3 +- .../rumbo/ui/utils/SensorOverlay.kt | 2 +- 3 files changed, 42 insertions(+), 12 deletions(-) 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 be3fa51..30e1e14 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 @@ -64,13 +64,13 @@ fun Avatar( isOnline: Boolean = false ) { val context = LocalContext.current - val pfp = user?.profilePictureUrl + 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 = remember(pfp) { - pfp?.let { + val imageRequest: ImageRequest? = remember(avatarUrl) { + avatarUrl?.let { ImageRequest.Builder(context) .data(it) .allowHardware(false) @@ -123,7 +123,7 @@ fun Avatar( modifier = Modifier .fillMaxSize() .clip(CircleShape), - error = { + loading = { Image( painter = painterResource(R.drawable.ic_user), contentDescription = contentDescription, @@ -133,18 +133,47 @@ 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 + ) + } } } } 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 ec50820..03d1ead 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 @@ -13,6 +13,7 @@ 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.shape.CircleShape import androidx.compose.material3.Button @@ -326,7 +327,7 @@ fun MapTemplate( true } ) { - DropNoteBubble(d = note) + DropNoteBubble(modifier = Modifier.size(64.dp),d = note) } } } 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 index 6c59833..5bfdcbd 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/SensorOverlay.kt @@ -113,7 +113,7 @@ fun CompassWidget( tint = MaterialTheme.colorScheme.primary, modifier = Modifier .size(16.dp) - .rotate(-animatedRotation) + .rotate(animatedRotation) ) Box( From f8b6759c7371c63a97682b3ad971beeb249ca5e2 Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Mon, 1 Jun 2026 15:55:08 -0500 Subject: [PATCH 27/49] [Feat]: Refactor drop note management, enhance view, and implement expiration Decouples the `DropNote` model from author display information, centralizing all drop note logic in a new `DropNoteViewModel`. This ViewModel handles real-time fetching, author resolution, upload, and deletion of drop notes. The `ViewDropNote` component is significantly enhanced to display author avatars, formatted timestamps, expandable images, and provides a delete option for owned notes. The `DropNoteBubble` styling and sizing on the map have also been refined. This refactoring improves data consistency, separation of concerns, and introduces a 12-hour expiration mechanism for public drop notes. --- .../appnotresponding/rumbo/models/dropNote.kt | 16 +- .../rumbo/ui/components/atoms/Avatar.kt | 2 +- .../molecules/map/DropNoteBubble.kt | 58 ++--- .../components/organisms/map/ViewDropnote.kt | 228 +++++++++++++---- .../rumbo/ui/templates/MapTemplate.kt | 64 +++-- .../rumbo/ui/viewModel/dropNoteViewModel.kt | 239 ++++++++++++++++++ .../rumbo/ui/viewModel/mapViewModel.kt | 133 +--------- 7 files changed, 496 insertions(+), 244 deletions(-) create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/dropNoteViewModel.kt 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 08d5212..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,32 +1,20 @@ package com.appnotresponding.rumbo.models + data class DropNote( val id: String = "", val creatorId: String = "", - val creatorName: String = "", - val creatorAvatarUrl: String? = null, 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 user: User - get() = User( - id = creatorId, - name = creatorName, - profilePictureUrl = creatorAvatarUrl - ) - val authorAvatarUrl: String? - get() = creatorAvatarUrl -} +) val sampleDropNote = DropNote( id = "1", creatorId = sampleUser.id, - creatorName = sampleUser.name, - creatorAvatarUrl = sampleUser.profilePictureUrl, public = true, content = "Hello! This is a sample drop note.", imageUrl = null, 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 30e1e14..e2c6d51 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 @@ -176,7 +176,6 @@ fun Avatar( } } } - } // Indicador de online if (isOnline) { @@ -188,6 +187,7 @@ fun Avatar( .border(indicatorBorderWidth, MaterialTheme.colorScheme.surface, CircleShape) ) } + } } } 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/organisms/map/ViewDropnote.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/ViewDropnote.kt index 6f142cc..44f9487 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 @@ -25,6 +25,53 @@ 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.ui.graphics.Color +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog + +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, @@ -32,6 +79,9 @@ fun ViewDropNote( content: String = "", imageUrl: String? = null, isPrivate: Boolean = false, + timestamp: Long = 0L, + showDeleteOption: Boolean = false, + onDeleteClick: () -> Unit = {} ) { Surface( modifier = modifier.fillMaxWidth(), @@ -39,73 +89,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( - modifier = Modifier.weight(1f), - 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( + imageVector = Icons.Rounded.Delete, + 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 (!imageUrl.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - AsyncImage( - model = imageUrl, - contentDescription = "Imagen de la DropNote", - modifier = Modifier - .fillMaxWidth() - .height(180.dp) - .clip(MaterialTheme.shapes.medium), - contentScale = ContentScale.Crop - ) + if (isImageExpanded) { + Dialog(onDismissRequest = { isImageExpanded = false }) { + AsyncImage( + model = imageUrl, + contentDescription = "Imagen ampliada", + modifier = Modifier + .fillMaxWidth() + .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/templates/MapTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt index 03d1ead..a6d1fa7 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 @@ -25,6 +25,7 @@ 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 @@ -72,6 +73,7 @@ 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.MapViewModel import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel @@ -106,6 +108,7 @@ fun MapTemplate( controller: NavHostController, onProfileClick: () -> Unit = {}, viewModel: MapViewModel = viewModel(), + dropNoteViewModel: DropNoteViewModel = viewModel(), placesViewModel: PlacesViewModel, locationViewModel: UserLocationViewModel ) { @@ -114,6 +117,7 @@ fun MapTemplate( 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() @@ -316,18 +320,27 @@ fun MapTemplate( ) } - state.dropNotes.forEach { note -> + dropNoteState.dropNotes.forEach { note -> val position = LatLng(note.latitude, note.longitude) - MarkerComposable( - state = rememberUpdatedMarkerState(position), - title = "DropNote de ${note.creatorName}", - onClick = { - selectedDropNote = note - popupStateViewDN = true - true + 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 + ) } - ) { - DropNoteBubble(modifier = Modifier.size(64.dp),d = note) } } } @@ -374,7 +387,7 @@ fun MapTemplate( if (popupStateDNComposer) { Dialog( onDismissRequest = { - if (!state.isUploadingNote) { + if (!dropNoteState.isUploadingNote) { popupStateDNComposer = false } }, properties = DialogProperties( @@ -388,7 +401,7 @@ fun MapTemplate( .background(Color.Black.copy(alpha = 0.55f)) .padding(20.dp), contentAlignment = Alignment.Center ) { - if (state.isUploadingNote) { + if (dropNoteState.isUploadingNote) { androidx.compose.material3.CircularProgressIndicator( color = MaterialTheme.colorScheme.primary ) @@ -409,15 +422,13 @@ fun MapTemplate( 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 - - viewModel.uploadAndSaveDropNote( + + dropNoteViewModel.uploadAndSaveDropNote( content = noteText, imageUri = mediaManager.imageUri, latitude = lat, longitude = lng, creatorId = user.id, - creatorName = "${user.name} ${user.lastname}", - creatorAvatarUrl = user.profilePictureUrl, onSuccess = { noteText = "" mediaManager.clearImage() @@ -458,10 +469,27 @@ fun MapTemplate( .padding(16.dp), contentAlignment = Alignment.Center ) { + val author = dropNoteState.dropNoteAuthors[selectedDropNote!!.creatorId] + ?: User(id = selectedDropNote!!.creatorId, name = "Usuario") ViewDropNote( - user = selectedDropNote!!.user, + user = author, content = selectedDropNote!!.content, - imageUrl = selectedDropNote!!.imageUrl + 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") + } + ) + } ) } } 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..9572635 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/dropNoteViewModel.kt @@ -0,0 +1,239 @@ +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) { + userCache[creatorId] = user + 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/mapViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt index 98da2d6..146fc8c 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt @@ -1,14 +1,7 @@ package com.appnotresponding.rumbo.ui.viewModel -import android.net.Uri import androidx.lifecycle.ViewModel -import com.appnotresponding.rumbo.models.DropNote import com.google.android.gms.maps.model.LatLng -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 @@ -25,127 +18,14 @@ data class MapState( val userRouteVisible: Boolean = false, val place: String = "", val centerInUserFirstTime: Boolean = true, - val lastSafeLatLng: LatLng = LatLng(0.0, 0.0), - val dropNotes: List = emptyList(), - val isUploadingNote: Boolean = false, - val noteUploadError: String = "" + val lastSafeLatLng: LatLng = LatLng(0.0, 0.0) ) -class MapViewModel: ViewModel() { +class MapViewModel : ViewModel() { private val _uiState = MutableStateFlow(MapState()) val uiState: StateFlow = _uiState.asStateFlow() - private val dbDropNotes = FirebaseDatabase.getInstance().getReference("dropNotes") - - init { - fetchDropNotes() - } - - private fun fetchDropNotes() { - dbDropNotes.addValueEventListener(object : ValueEventListener { - override fun onDataChange(snapshot: DataSnapshot) { - val notes = mutableListOf() - for (child in snapshot.children) { - val note = child.getValue(DropNote::class.java) - if (note != null) { - notes.add(note) - } - } - _uiState.update { it.copy(dropNotes = notes) } - } - - override fun onCancelled(error: DatabaseError) { - // Manejo de error - } - }) - } - - fun uploadAndSaveDropNote( - content: String, - imageUri: Uri?, - latitude: Double, - longitude: Double, - creatorId: String, - creatorName: String, - creatorAvatarUrl: 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, - creatorName = creatorName, - creatorAvatarUrl = creatorAvatarUrl, - onSuccess = onSuccess - ) - }.addOnFailureListener { e -> - _uiState.update { it.copy(isUploadingNote = false, noteUploadError = e.message ?: "Error al obtener URL de descarga") } - } - } - .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, - creatorName = creatorName, - creatorAvatarUrl = creatorAvatarUrl, - onSuccess = onSuccess - ) - } - } - - private fun saveDropNoteMetadata( - noteId: String, - content: String, - imageUrl: String?, - latitude: Double, - longitude: Double, - creatorId: String, - creatorName: String, - creatorAvatarUrl: String?, - onSuccess: () -> Unit - ) { - val dropNote = DropNote( - id = noteId, - creatorId = creatorId, - creatorName = creatorName, - creatorAvatarUrl = creatorAvatarUrl, - 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 updatePlace(place: String) { _uiState.update { it.copy(place = place) } } @@ -155,12 +35,12 @@ class MapViewModel: ViewModel() { } fun updateUserMarker(lat: Double, lng: Double) { - val newLatLng = LatLng(lat, lng) - _uiState.update { it.copy(userMarker = MyMarker(newLatLng)) } + _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 updateAdditionalMarker(position: LatLng, title: String) { _uiState.update { it.copy(additionalMarker = MyMarker(position), additionalMarkerVisible = true + ) + } } fun cancelAdditionalMarkerVisibility() { @@ -182,5 +62,4 @@ class MapViewModel: ViewModel() { fun updateUserRoutePoints(points: List) { _uiState.update { it.copy(userRoutePoints = points) } } - } \ No newline at end of file From 682b16748c25f4af7118ea55eec9e1baea7eb980 Mon Sep 17 00:00:00 2001 From: Miguel4950 Date: Mon, 1 Jun 2026 19:12:28 -0500 Subject: [PATCH 28/49] Implementar gestion de amigos, chat 1-1, chat grupal contextual y mejoras de rumbo --- app/src/main/AndroidManifest.xml | 1 + .../rumbo/models/chatConversation.kt | 19 ++ .../rumbo/models/chatMessage.kt | 12 + .../rumbo/models/placeState.kt | 5 +- .../com/appnotresponding/rumbo/models/user.kt | 7 +- .../rumbo/navigation/navigation.kt | 47 +-- .../components/molecules/chat/ChatBubble.kt | 195 ++++++++++-- .../molecules/chat/MessageComposer.kt | 2 +- .../molecules/friends/FriendRequestItem.kt | 85 +++++ .../molecules/friends/UserSearchResultItem.kt | 94 ++++++ .../components/organisms/chat/ChatThread.kt | 2 + .../ui/components/organisms/common/Nav.kt | 2 +- .../ui/components/organisms/common/TopBar.kt | 78 ++++- .../organisms/friends/FriendsList.kt | 53 ++++ .../rumbo/ui/screens/chat/ChatListScreen.kt | 249 +++++++++++---- .../rumbo/ui/screens/chat/ChatThreadScreen.kt | 296 ++++++++++++----- .../rumbo/ui/screens/friends/FriendsScreen.kt | 152 +++++++++ .../rumbo/ui/screens/map/MapScreen.kt | 8 +- .../rumbo/ui/templates/ChatThreadTemplate.kt | 36 ++- .../rumbo/ui/templates/FriendsTemplate.kt | 59 ++++ .../rumbo/ui/templates/MapTemplate.kt | 71 ++++- .../rumbo/ui/viewModel/chatThreadViewModel.kt | 272 ++++++++++++++++ .../rumbo/ui/viewModel/chatViewModel.kt | 298 ++++++++++++++++++ .../rumbo/ui/viewModel/friendsViewModel.kt | 248 +++++++++++++++ .../rumbo/ui/viewModel/placesViewModel.kt | 8 + .../rumbo/ui/viewModel/userViewModel.kt | 44 ++- 26 files changed, 2113 insertions(+), 230 deletions(-) create mode 100644 app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/friends/FriendsList.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1902b82..593be21 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + = emptyMap() +) diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt b/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt new file mode 100644 index 0000000..fdf4914 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt @@ -0,0 +1,12 @@ +package com.appnotresponding.rumbo.models + +data class ChatMessage( + val id: String = "", + val senderId: String = "", + val senderName: String = "", + val text: String = "", + val timestamp: Long = 0, + val type: String = "text", + val placeId: String? = null, + val mediaUrl: String? = null +) diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt index d94f998..8bc544e 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt @@ -1,7 +1,10 @@ package com.appnotresponding.rumbo.models +import com.google.android.gms.maps.model.LatLng + data class PlaceState( val availablePlaces: List = emptyList(), val itinerary: List = emptyList(), - val selectedPlace: Place? = null + val selectedPlace: Place? = null, + val focusLocation: LatLng? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/user.kt b/app/src/main/java/com/appnotresponding/rumbo/models/user.kt index 419bab2..5b5c671 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/user.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/user.kt @@ -10,7 +10,9 @@ data class User( val latitude: Double = 0.0, val longitude: Double = 0.0, val altitude: Double = 0.0, - val profilePictureUrl: String? = null + val profilePictureUrl: String? = null, + val sharingLocation: Boolean = false, + val activity: String? = null ) val sampleUser = User( @@ -22,5 +24,6 @@ val sampleUser = User( latitude = 0.0, longitude = 0.0, altitude = 0.0, - profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg" + profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg", + sharingLocation = false ) \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt index 526fd99..941118b 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt @@ -1,7 +1,6 @@ package com.appnotresponding.rumbo.navigation import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -10,20 +9,25 @@ import com.appnotresponding.rumbo.ui.screens.auth.LogInScreen import com.appnotresponding.rumbo.ui.screens.auth.SignUpScreen import com.appnotresponding.rumbo.ui.screens.chat.ChatListScreen import com.appnotresponding.rumbo.ui.screens.chat.ChatThreadScreen +import com.appnotresponding.rumbo.ui.screens.friends.FriendsScreen import com.appnotresponding.rumbo.ui.screens.itinerary.ItineraryScreen import com.appnotresponding.rumbo.ui.screens.map.MapScreen import com.appnotresponding.rumbo.ui.screens.onboarding.OnBoardingScreen import com.appnotresponding.rumbo.ui.screens.plan.PlanScreen import com.appnotresponding.rumbo.ui.screens.splash.SplashScreen +import com.appnotresponding.rumbo.ui.viewModel.ChatThreadViewModel +import com.appnotresponding.rumbo.ui.viewModel.ChatViewModel +import com.appnotresponding.rumbo.ui.viewModel.FriendsViewModel import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel -import androidx.lifecycle.ViewModel import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel - import com.appnotresponding.rumbo.ui.viewModel.UserViewModel val placesViewModel: PlacesViewModel = PlacesViewModel() +val chatViewModel: ChatViewModel = ChatViewModel() +val chatThreadViewModel: ChatThreadViewModel = ChatThreadViewModel() +val friendsViewModel: FriendsViewModel = FriendsViewModel() -enum class AppScreens{ +enum class AppScreens { Splash, LogIn, SignUp, @@ -32,43 +36,46 @@ enum class AppScreens{ ChatThread, Plan, Itinerary, - OnBoarding + OnBoarding, + Friends } @Composable fun Navigation( locationViewModel: UserLocationViewModel = viewModel(), userViewModel: UserViewModel = viewModel() -){ - val context = LocalContext.current +) { val navController = rememberNavController() - NavHost(navController=navController, startDestination = AppScreens.Splash.name){ - composable (route = AppScreens.Splash.name){ + NavHost(navController = navController, startDestination = AppScreens.Splash.name) { + composable(route = AppScreens.Splash.name) { SplashScreen(navController) } - composable(route = AppScreens.LogIn.name){ + composable(route = AppScreens.LogIn.name) { LogInScreen(navController) } - composable (route = AppScreens.SignUp.name){ + composable(route = AppScreens.SignUp.name) { SignUpScreen(navController) } - composable (route = AppScreens.Map.name) { - MapScreen(navController, placesViewModel, locationViewModel, userViewModel) + composable(route = AppScreens.Map.name) { + MapScreen(navController, placesViewModel, locationViewModel, userViewModel, friendsViewModel) } - composable (route = AppScreens.Chat.name) { - ChatListScreen(navController, userViewModel) + composable(route = AppScreens.Chat.name) { + ChatListScreen(navController, userViewModel, chatViewModel, placesViewModel) } - composable(route = AppScreens.ChatThread.name){ - ChatThreadScreen(navController) + composable(route = AppScreens.ChatThread.name) { + ChatThreadScreen(navController, chatViewModel, chatThreadViewModel, userViewModel, locationViewModel, placesViewModel) } - composable(route = AppScreens.Plan.name){ + composable(route = AppScreens.Plan.name) { PlanScreen(navController, placesViewModel, locationViewModel, userViewModel) } - composable(route = AppScreens.Itinerary.name){ + composable(route = AppScreens.Itinerary.name) { ItineraryScreen(navController, placesViewModel, userViewModel) } - composable(route = AppScreens.OnBoarding.name){ + composable(route = AppScreens.OnBoarding.name) { OnBoardingScreen(navController) } + composable(route = AppScreens.Friends.name) { + FriendsScreen(navController, userViewModel, friendsViewModel, chatViewModel) + } } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt index d2f49ab..ad69ace 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt @@ -2,6 +2,7 @@ package com.appnotresponding.rumbo.ui.components.molecules.chat import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,11 +11,17 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -23,6 +30,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage @@ -35,6 +44,8 @@ import com.appnotresponding.rumbo.ui.components.atoms.RumboButton import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonSize import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle import com.appnotresponding.rumbo.ui.theme.RumboTheme +import android.media.MediaPlayer +import androidx.compose.ui.unit.sp @Composable fun ChatSeparator(text: String) { @@ -82,11 +93,14 @@ enum class ChatBubbleType { @Composable fun ChatBubble( message: String, - messageImage: ImageRequest? = null, + mediaUrl: String? = null, + mediaType: String? = null, isUserMessage: Boolean, senderName: String? = null, + senderActivity: String? = null, type: ChatBubbleType = ChatBubbleType.Regular, - place: Place? = null + place: Place? = null, + onLocationClick: (() -> Unit)? = null ) { val horizontalAlignment = if (isUserMessage) { Alignment.End @@ -112,6 +126,22 @@ fun ChatBubble( Arrangement.Start } + val bubbleShape = if (isUserMessage) { + RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = 16.dp, + bottomEnd = 4.dp + ) + } else { + RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = 4.dp, + bottomEnd = 16.dp + ) + } + when (type) { ChatBubbleType.Regular -> { Row( @@ -120,36 +150,125 @@ fun ChatBubble( Column( modifier = Modifier .widthIn(max = 280.dp) - .background(backgroundColor, MaterialTheme.shapes.large), + .then( + if (mediaUrl != null) Modifier.width(240.dp) else Modifier + ) + .background(backgroundColor, bubbleShape), horizontalAlignment = horizontalAlignment, verticalArrangement = Arrangement.spacedBy(4.dp), ) { Column( - modifier = Modifier.padding(12.dp), - horizontalAlignment = horizontalAlignment, + modifier = Modifier + .then(if (mediaUrl != null) Modifier.fillMaxWidth() else Modifier) + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (senderName != null) { - Text( - text = senderName, - style = MaterialTheme.typography.labelLarge, - color = contentColor - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 2.dp) + ) { + Text( + text = senderName, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = contentColor.copy(alpha = 0.8f) + ) + if (!senderActivity.isNullOrBlank()) { + Text( + text = " · ", + style = MaterialTheme.typography.labelMedium, + color = contentColor.copy(alpha = 0.6f) + ) + Text( + text = senderActivity, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = contentColor + ) + } + } } - if (messageImage != null) { + if (mediaUrl != null && mediaType == "image") { AsyncImage( - modifier = Modifier.clip(MaterialTheme.shapes.medium), - model = messageImage, + modifier = Modifier.clip(MaterialTheme.shapes.medium).fillMaxWidth(), + model = mediaUrl, + contentScale = ContentScale.FillWidth, contentDescription = null ) + } else if (mediaUrl != null && mediaType == "audio") { + var isPlaying by remember { mutableStateOf(false) } + var isPreparing by remember { mutableStateOf(false) } + val mediaPlayer = remember { MediaPlayer() } + + DisposableEffect(mediaUrl) { + onDispose { + mediaPlayer.release() + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium) + .clickable { + try { + if (isPlaying) { + mediaPlayer.stop() + mediaPlayer.reset() + isPlaying = false + } else if (!isPreparing) { + isPreparing = true + mediaPlayer.reset() + mediaPlayer.setDataSource(mediaUrl) + mediaPlayer.setOnPreparedListener { + isPreparing = false + isPlaying = true + mediaPlayer.start() + } + mediaPlayer.setOnCompletionListener { + isPlaying = false + } + mediaPlayer.setOnErrorListener { _, _, _ -> + isPreparing = false + isPlaying = false + true + } + mediaPlayer.prepareAsync() + } + } catch (e: Exception) { + e.printStackTrace() + isPreparing = false + isPlaying = false + } + } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + val icon = if (isPreparing) "⏳" else if (isPlaying) "⏸" else "▶" + Text(icon, color = MaterialTheme.colorScheme.primary) + Text( + text = if (isPreparing) "Preparando..." else if (isPlaying) "Reproduciendo..." else "Nota de voz", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + val isMediaPlaceholder = mediaUrl != null && (message == "📷 Imagen" || message == "🎤 Nota de voz") + if (!isMediaPlaceholder && message.isNotBlank()) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge.copy( + lineHeight = 22.sp + ), + color = contentColor + ) } - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = contentColor - ) } } } @@ -165,9 +284,10 @@ fun ChatBubble( .widthIn(max = 280.dp) .background( backgroundColor, - MaterialTheme.shapes.large + bubbleShape ) - .clip(MaterialTheme.shapes.large), + .clip(bubbleShape) + .clickable(enabled = onLocationClick != null) { onLocationClick?.invoke() }, ) { Row( modifier = Modifier @@ -211,7 +331,7 @@ fun ChatBubble( Column( modifier = Modifier .widthIn(max = 280.dp) - .background(backgroundColor, MaterialTheme.shapes.large), + .background(backgroundColor, bubbleShape), horizontalAlignment = horizontalAlignment, verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -222,11 +342,28 @@ fun ChatBubble( verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (senderName != null) { - Text( - text = senderName, - style = MaterialTheme.typography.labelLarge, - color = contentColor - ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = senderName, + style = MaterialTheme.typography.labelLarge, + color = contentColor + ) + if (!senderActivity.isNullOrBlank()) { + Text( + text = " · ", + style = MaterialTheme.typography.labelLarge, + color = contentColor.copy(alpha = 0.6f) + ) + Text( + text = senderActivity, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = contentColor + ) + } + } } Text( @@ -307,7 +444,8 @@ private fun ChatBubblePreviewContent() { // Regular - received with image ChatBubble( message = "¡Hola! ¿Cómo estás?", - messageImage = placeholderImage, + mediaUrl = null, + mediaType = null, isUserMessage = false, senderName = "Carlos", type = ChatBubbleType.Regular @@ -315,7 +453,8 @@ private fun ChatBubblePreviewContent() { // Regular - sent with image ChatBubble( message = "¡Todo bien! ¿Y tú?", - messageImage = placeholderImage, + mediaUrl = null, + mediaType = null, isUserMessage = true, senderName = null, type = ChatBubbleType.Regular diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt index 7c203e5..004fca5 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt @@ -102,7 +102,7 @@ fun MessageComposer( IconButton(onClick = onLocationClick, modifier = Modifier.size(40.dp)) { Icon( painter = painterResource(id = R.drawable.ic_marker), - contentDescription = "Enviar ubicación", + contentDescription = "Compartir ubicación", modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt new file mode 100644 index 0000000..0e5b70c --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt @@ -0,0 +1,85 @@ +package com.appnotresponding.rumbo.ui.components.molecules.friends + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.appnotresponding.rumbo.R +import com.appnotresponding.rumbo.models.User +import com.appnotresponding.rumbo.ui.components.atoms.Avatar +import com.appnotresponding.rumbo.ui.components.atoms.RumboButton +import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonSize +import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle + +@Composable +fun FriendRequestItem( + user: User, + onAcceptClick: () -> Unit, + onDeclineClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + shape = MaterialTheme.shapes.medium + ) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.medium + ) + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(user = user) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "${user.name} ${user.lastname}", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Te envió una solicitud", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RumboButton( + text = "Aceptar", + onClick = onAcceptClick, + style = RumboButtonStyle.Primary, + size = RumboButtonSize.Small, + icon = painterResource(R.drawable.ic_check) + ) + RumboButton( + text = "Rechazar", + onClick = onDeclineClick, + style = RumboButtonStyle.Secondary, + size = RumboButtonSize.Small, + icon = painterResource(R.drawable.outline_cancel_24) + ) + } + } + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt new file mode 100644 index 0000000..0c137a7 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/UserSearchResultItem.kt @@ -0,0 +1,94 @@ +package com.appnotresponding.rumbo.ui.components.molecules.friends + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.appnotresponding.rumbo.R +import com.appnotresponding.rumbo.models.User +import com.appnotresponding.rumbo.ui.components.atoms.Avatar +import com.appnotresponding.rumbo.ui.components.atoms.RumboButton +import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonSize +import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle + +@Composable +fun UserSearchResultItem( + user: User, + isAlreadyFriend: Boolean, + isPending: Boolean = false, + onAddClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.medium + ) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = MaterialTheme.shapes.medium + ) + .padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Avatar(user = user) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "${user.name} ${user.lastname}", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = user.email, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (isAlreadyFriend) { + RumboButton( + text = "Amigos", + onClick = {}, + enabled = false, + style = RumboButtonStyle.Secondary, + size = RumboButtonSize.Small, + icon = painterResource(R.drawable.ic_check) + ) + } else if (isPending) { + RumboButton( + text = "Pendiente", + onClick = {}, + enabled = false, + style = RumboButtonStyle.Secondary, + size = RumboButtonSize.Small, + icon = painterResource(R.drawable.ic_user) + ) + } else { + RumboButton( + text = "Agregar", + onClick = onAddClick, + style = RumboButtonStyle.Primary, + size = RumboButtonSize.Small, + icon = painterResource(R.drawable.ic_user_add) + ) + } + } + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatThread.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatThread.kt index 61a5434..1fed3a5 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatThread.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatThread.kt @@ -18,6 +18,7 @@ data class ChatMessageData( val message: String, val isUserMessage: Boolean, val senderName: String? = null, + val senderActivity: String? = null, val type: ChatBubbleType = ChatBubbleType.Regular, val place: Place? = null, val isSeparator: Boolean = false @@ -44,6 +45,7 @@ fun ChatThread( message = msg.message, isUserMessage = msg.isUserMessage, senderName = msg.senderName, + senderActivity = msg.senderActivity, type = msg.type, place = msg.place ) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt index a9681b6..cf38d8d 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt @@ -47,7 +47,7 @@ fun Nav( val currentRoute = navBackStackEntry?.destination?.route val activeItem = when (currentRoute) { AppScreens.Map.name -> NavItem.Map - AppScreens.Chat.name, AppScreens.ChatThread.name -> NavItem.Chat + AppScreens.Chat.name, AppScreens.ChatThread.name, AppScreens.Friends.name -> NavItem.Chat AppScreens.Plan.name -> NavItem.Plan AppScreens.Itinerary.name -> NavItem.Itinerary else -> NavItem.Map diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt index 35112ac..2ac68f4 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt @@ -9,6 +9,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ExitToApp +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -78,7 +85,15 @@ fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) { * (por ejemplo, "Rumbo al Museo Nacional"). */ @Composable -fun ChatTopBar(u: User, activity: String? = null) { +fun ChatTopBar( + u: User, + activity: String? = null, + isGroup: Boolean = false, + isMuted: Boolean = false, + onMuteClick: (() -> Unit)? = null, + onLeaveClick: (() -> Unit)? = null, + onBackClick: (() -> Unit)? = null +) { val bottomRoundedShape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) val displayName = u.name.replace(Regex(" +$"), "") Surface( @@ -89,24 +104,57 @@ fun ChatTopBar(u: User, activity: String? = null) { .fillMaxWidth() .padding(16.dp) .padding(top = 32.dp), - horizontalArrangement = Arrangement.Start + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Avatar(user = u) - Column { - - Text( - text = displayName, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(start = 8.dp), - color = MaterialTheme.colorScheme.onSurface - ) - if (activity != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (onBackClick != null) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Atrás", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + Avatar(user = u) + Column { Text( - text = activity, - style = MaterialTheme.typography.labelMedium, + text = displayName, + style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(start = 8.dp), - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.onSurface ) + if (!activity.isNullOrBlank()) { + Text( + text = activity, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 8.dp), + color = MaterialTheme.colorScheme.primary + ) + } + } + } + if (isGroup) { + Row { + if (onMuteClick != null) { + IconButton(onClick = onMuteClick) { + Icon( + imageVector = if (isMuted) Icons.Filled.NotificationsOff else Icons.Filled.Notifications, + contentDescription = if (isMuted) "Desilenciar" else "Silenciar", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (onLeaveClick != null) { + IconButton(onClick = onLeaveClick) { + Icon( + imageVector = Icons.Filled.ExitToApp, + contentDescription = "Salir", + tint = MaterialTheme.colorScheme.error + ) + } + } } } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/friends/FriendsList.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/friends/FriendsList.kt new file mode 100644 index 0000000..a7f14ac --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/friends/FriendsList.kt @@ -0,0 +1,53 @@ +package com.appnotresponding.rumbo.ui.components.organisms.friends + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.appnotresponding.rumbo.models.User +import com.appnotresponding.rumbo.ui.components.molecules.friends.UserSearchResultItem + +@Composable +fun FriendsList( + friends: List, + modifier: Modifier = Modifier, + onFriendClick: (User) -> Unit = {} +) { + if (friends.isEmpty()) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Aún no tienes amigos en Rumbo", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + return + } + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 100.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(friends) { friend -> + UserSearchResultItem( + modifier = Modifier.clickable { onFriendClick(friend) }, + user = friend, + isAlreadyFriend = true, + onAddClick = {} + ) + } + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt index a9f69d5..1ca3ba2 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt @@ -1,85 +1,204 @@ package com.appnotresponding.rumbo.ui.screens.chat - +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController +import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.auth +import com.appnotresponding.rumbo.models.User import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens -import com.appnotresponding.rumbo.ui.components.organisms.chat.ChatList -import com.appnotresponding.rumbo.ui.components.organisms.chat.ChatPreviewData -import com.appnotresponding.rumbo.ui.templates.ChatTemplate +import com.appnotresponding.rumbo.ui.components.molecules.chat.ChatListItem +import com.appnotresponding.rumbo.ui.components.organisms.common.MainTopBar +import com.appnotresponding.rumbo.ui.components.organisms.common.Nav +import com.appnotresponding.rumbo.ui.viewModel.ChatViewModel +import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel import com.appnotresponding.rumbo.ui.viewModel.UserViewModel - +import com.google.firebase.auth.FirebaseAuth @Composable -fun ChatListScreen(controller: NavHostController, userViewModel: UserViewModel) { +fun ChatListScreen( + controller: NavHostController, + userViewModel: UserViewModel, + chatViewModel: ChatViewModel, + placesViewModel: PlacesViewModel +) { val userState by userViewModel.currentUserState.collectAsState() val currentUser = userState ?: sampleUser.copy(name = "Cargando...") + val chatState by chatViewModel.uiState.collectAsState() + val placesState by placesViewModel.uiState.collectAsState() + + LaunchedEffect(placesState.itinerary) { + chatViewModel.listenToGroupChats(placesState.itinerary) + } + + val myUid = FirebaseAuth.getInstance().currentUser?.uid ?: "" - val mockChats = listOf( - ChatPreviewData( - sampleUser.copy(name = "Brandon"), - "¡Ya estoy cerca! ...", - "Rumbo al Museo Nacional", - "", - true - ), - ChatPreviewData( - sampleUser.copy(name = "Aylean"), - "¿Nos vemos allá?", - "Rumbo al Museo Nacional", - "", - false - ), - ChatPreviewData( - sampleUser.copy(name = "Ahbdul"), - "¡Ya estoy cerca! ...", - "Rumbo al Museo Nacional", - "", - false - ), - ChatPreviewData( - sampleUser.copy(name = "Los Mochileros"), - "@Ana, dónde estás?!", - "Rumbo al Museo N...", - "", - true - ), - ChatPreviewData(sampleUser.copy(name = "Kyle"), "Fué un gusto conocerte!", null, "", false), - ChatPreviewData(sampleUser.copy(name = "Ashley"), "¡Ya estoy cerca! ...", null, "", false), - ChatPreviewData(sampleUser.copy(name = "Tatiana"), "¡Ya estoy cerca! ...", null, "", false) - ) + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + MainTopBar(u = currentUser, onProfileClick = { + auth.signOut() + controller.navigate(AppScreens.Splash.name) { + popUpTo(controller.graph.startDestinationId) { inclusive = true } + launchSingleTop = true + } + }) + }, + bottomBar = { Nav(controller) }, + floatingActionButton = { + FloatingActionButton( + onClick = { controller.navigate(AppScreens.Friends.name) }, + containerColor = MaterialTheme.colorScheme.primary + ) { + Icon( + painter = painterResource(R.drawable.ic_user_add), + contentDescription = "Amigos", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)) { + Text( + text = "Chats", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Mensajes en tiempo real", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (chatState.directChats.isNotEmpty()) { + item { + Text( + text = "Directos", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + items(chatState.directChats) { convo -> + val friendUser = User( + id = convo.otherUserId, + name = convo.otherUserName, + profilePictureUrl = convo.otherUserPhotoUrl, + activity = convo.otherUserActivity + ) + ChatListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { + chatViewModel.selectDirectChat( + chatId = convo.chatId, + chatTitle = convo.otherUserName, + photoUrl = convo.otherUserPhotoUrl + ) + controller.navigate(AppScreens.ChatThread.name) + }, + user = friendUser, + lastMessage = convo.lastMessage, + status = convo.otherUserActivity, + timestamp = formatTimestamp(convo.lastMessageTimestamp), + hasUnread = false + ) + } + } - ChatTemplate( - currentUser = currentUser, - title = "Chats", - subtitle = "Ubicación actual: Bogotá", - controller = controller, - onProfileClick = { - auth.signOut() - controller.navigate(AppScreens.Splash.name) { - popUpTo(controller.graph.startDestinationId) { inclusive = true } - launchSingleTop = true + if (chatState.groupChats.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Grupos", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + items(chatState.groupChats) { group -> + val isMuted = group.mutedBy[myUid] == true + val groupUser = User(name = group.placeName) + ChatListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { + chatViewModel.selectGroupChat( + placeId = group.placeId, + placeName = group.placeName + ) + controller.navigate(AppScreens.ChatThread.name) + }, + user = groupUser, + lastMessage = if (isMuted) "🔇 Silenciado" else group.lastMessage, + status = "Grupo", + timestamp = formatTimestamp(group.lastMessageTimestamp), + hasUnread = false + ) + } + } + + if (chatState.directChats.isEmpty() && chatState.groupChats.isEmpty()) { + item { + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = "No tienes chats aún.\nAgrega amigos con el botón +", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } } - }) { - ChatList( - chatItems = mockChats, - onChatClick = { controller.navigate(AppScreens.ChatThread.name) }) + } } } -// -//@Preview( -// showBackground = true, -// name = "3. Pantalla Lista de Chats demostracion", -// backgroundColor = 0xFF121212 -//) -//@Composable -//private fun ChatListScreenPreview() { -// RumboTheme(darkTheme = true) { -// ChatListScreen(controller = rememberNavController()) -// } -//} \ No newline at end of file +private fun formatTimestamp(timestamp: Long): String { + if (timestamp == 0L) return "" + val now = System.currentTimeMillis() + val diff = now - timestamp + return when { + diff < 60_000 -> "Ahora" + diff < 3_600_000 -> "${diff / 60_000}m" + diff < 86_400_000 -> "${diff / 3_600_000}h" + else -> "${diff / 86_400_000}d" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt index 945c4d0..83e0ff1 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt @@ -1,112 +1,240 @@ package com.appnotresponding.rumbo.ui.screens.chat +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import android.Manifest +import android.content.pm.PackageManager +import android.media.MediaRecorder +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.core.content.ContextCompat +import androidx.compose.ui.platform.LocalContext +import java.io.File import androidx.compose.runtime.setValue -import androidx.compose.ui.tooling.preview.Preview -import androidx.navigation.NavController +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController -import com.appnotresponding.rumbo.models.samplePlace import com.appnotresponding.rumbo.models.sampleUser -import com.appnotresponding.rumbo.navigation.AppScreens +import com.appnotresponding.rumbo.ui.components.molecules.chat.ChatBubble import com.appnotresponding.rumbo.ui.components.molecules.chat.ChatBubbleType -import com.appnotresponding.rumbo.ui.components.organisms.chat.ChatMessageData -import com.appnotresponding.rumbo.ui.components.organisms.chat.ChatThread import com.appnotresponding.rumbo.ui.templates.ChatThreadTemplate -import com.appnotresponding.rumbo.ui.theme.RumboTheme +import com.appnotresponding.rumbo.ui.viewModel.ChatThreadViewModel +import com.appnotresponding.rumbo.ui.viewModel.ChatViewModel +import com.appnotresponding.rumbo.ui.viewModel.UserViewModel +import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel +import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel +import com.appnotresponding.rumbo.navigation.AppScreens @Composable fun ChatThreadScreen( - controller: NavHostController + controller: NavHostController, + chatViewModel: ChatViewModel, + chatThreadViewModel: ChatThreadViewModel, + userViewModel: UserViewModel, + locationViewModel: UserLocationViewModel, + placesViewModel: PlacesViewModel ) { + val chatState by chatViewModel.uiState.collectAsState() + val threadState by chatThreadViewModel.uiState.collectAsState() + val userState by userViewModel.currentUserState.collectAsState() + val currentUser = userState ?: sampleUser + val locationState by locationViewModel.uiState.collectAsState() + var messageInput by remember { mutableStateOf("") } - val brandonUser = sampleUser.copy(name = "Brandon") + val listState = rememberLazyListState() - val museoNacional = samplePlace.copy( - name = "Museo Nacional", - openHours = emptyList(), - price = "$ 40.000 COP" - ) + val chatId = chatState.selectedChatId + val isGroup = chatState.isGroupChat - val messages = listOf( - ChatMessageData("Hola! Vamos al Museo Nacional?", isUserMessage = true), - ChatMessageData("Hola! De una!", isUserMessage = false), - ChatMessageData("", isUserMessage = false, type = ChatBubbleType.LiveActivity, place = museoNacional), - ChatMessageData("Iniciaste una ruta compartida", isUserMessage = false, isSeparator = true), - ChatMessageData("Ya estoy en camino!", isUserMessage = true), - ChatMessageData("¡Ya estoy cerca!\nTe parece si nos vemos en la entrada?", isUserMessage = false) - ) - ChatThreadTemplate( - chatTitle = brandonUser.name, - chatSubtitle = "", - chatAvatarUser = brandonUser, - messageInputValue = messageInput, - onMessageInputValueChange = { messageInput = it }, - onSendClick = { - messageInput = "" - }) { - ChatThread(messages = messages) - } -} + var mediaRecorder by remember { mutableStateOf(null) } + var audioFile by remember { mutableStateOf(null) } + var isRecording by remember { mutableStateOf(false) } -@Preview(showBackground = true, name = "4A. Hilo de Chat (1 a 1) - Demo", backgroundColor = 0xFF121212, heightDp = 800) -@Composable -fun ChatThreadOneOnOnePreview() { - val brandonUser = sampleUser.copy(name = "Brandon") + val context = LocalContext.current - val museoNacional = samplePlace.copy( - name = "Museo Nacional", openHours = emptyList(), price = "$ 40.000 COP" - ) + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + if (uri != null) { + chatThreadViewModel.sendMediaMessage(chatId, currentUser.name, uri, isGroup, "image") + } + } - val mockMessages = listOf( - ChatMessageData("Hola! Vamos al Museo Nacional?", isUserMessage = true), - ChatMessageData("Hola! De una!", isUserMessage = false), - ChatMessageData( - "", isUserMessage = false, type = ChatBubbleType.LiveActivity, place = museoNacional - ), - ChatMessageData("Iniciaste una ruta compartida", isUserMessage = false, isSeparator = true), - ChatMessageData("Ya estoy en camino!", isUserMessage = true), - ChatMessageData( - "¡Ya estoy cerca!\nTe parece si nos vemos en la entrada?", isUserMessage = false - ) - ) + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + // Handle permission result if needed + } - RumboTheme(darkTheme = true) { - ChatThreadScreen(controller = rememberNavController()) + LaunchedEffect(chatId) { + if (chatId.isNotBlank()) { + if (isGroup) { + chatThreadViewModel.listenToGroupMessages(chatId) + } else { + chatThreadViewModel.listenToMessages(chatId) + } + } } -} -@Preview( - showBackground = true, - name = "4.1. Hilo de Chat Grupal demostrcion", - backgroundColor = 0xFF121212, - heightDp = 800 -) -@Composable -fun ChatThreadGroupPreview() { - val groupAvatar = sampleUser.copy(name = "Grupo") - - val mockGroupMessages = listOf( - ChatMessageData("Hola! Cómo van??", isUserMessage = true), - ChatMessageData( - "Hola! Yo estoy saliendo del hotel", isUserMessage = false, senderName = "Brandon" - ), - ChatMessageData( - "Yo ya llegué, acá los espero", isUserMessage = false, senderName = "Ahbdul" - ), - ChatMessageData("@Ashley, dónde vienes?", isUserMessage = true), - ChatMessageData("Creo que estoy perdida 😭", isUserMessage = false, senderName = "Ashley"), - ChatMessageData("Mentira, ya estoy con los demás", isUserMessage = true), - ChatMessageData("@Ana, dónde estás?!", isUserMessage = false, senderName = "Ana"), - ChatMessageData("", isUserMessage = true, type = ChatBubbleType.Location) + LaunchedEffect(threadState.messages.size) { + if (threadState.messages.isNotEmpty()) { + listState.animateScrollToItem(threadState.messages.size - 1) + } + } + + val avatarUser = sampleUser.copy( + name = chatState.selectedChatTitle, + profilePictureUrl = chatState.selectedChatPhoto ) - RumboTheme(darkTheme = true) { - ChatThreadScreen( - controller = rememberNavController() - ) + val isMuted = chatState.groupChats.find { it.placeId == chatId }?.mutedBy?.get(currentUser.id) == true + + val otherUid = chatId.split("_").firstOrNull { it != currentUser.id } + val otherUser = threadState.messageAuthors[otherUid] + + ChatThreadTemplate( + chatTitle = chatState.selectedChatTitle, + chatSubtitle = if (isGroup) "Chat grupal" else (otherUser?.activity ?: ""), + chatAvatarUser = avatarUser, + isGroup = isGroup, + isMuted = isMuted, + onMuteClick = { + if (isMuted) { + chatViewModel.unmuteGroup(chatId) + } else { + chatViewModel.muteGroup(chatId) + } + }, + onLeaveClick = { + chatViewModel.leaveGroup(chatId) + controller.navigateUp() + }, + onBackClick = { + controller.navigateUp() + }, + messageInputValue = messageInput, + onMessageInputValueChange = { messageInput = it }, + onSendClick = { + val text = messageInput.trim() + if (text.isNotBlank()) { + if (isGroup) { + chatThreadViewModel.sendGroupMessage(chatId, currentUser.name, text) + } else { + chatThreadViewModel.sendMessage(chatId, text) + } + messageInput = "" + } + }, + onImageClick = { + imagePickerLauncher.launch("image/*") + }, + onLocationClick = { + val lat = locationState.latitude + val lng = locationState.longitude + val finalLat = if (lat != 0.0) lat else 4.627293 + val finalLng = if (lng != 0.0) lng else -74.063228 + chatThreadViewModel.sendLocationMessage(chatId, currentUser.name, finalLat, finalLng, isGroup) + }, + onMicClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + if (isRecording) { + mediaRecorder?.stop() + mediaRecorder?.release() + mediaRecorder = null + isRecording = false + audioFile?.let { file -> + chatThreadViewModel.sendMediaMessage(chatId, currentUser.name, Uri.fromFile(file), isGroup, "audio") + } + } else { + audioFile = File.createTempFile("audio", ".mp4", context.cacheDir) + val recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + recorder.setAudioSource(MediaRecorder.AudioSource.MIC) + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + recorder.setOutputFile(audioFile!!.absolutePath) + try { + recorder.prepare() + recorder.start() + mediaRecorder = recorder + isRecording = true + } catch (e: Exception) { + e.printStackTrace() + } + } + } else { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + ) { + if (threadState.messages.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "Sin mensajes aún. ¡Di hola!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + state = listState, + contentPadding = PaddingValues(top = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(threadState.messages) { msg -> + val isMine = msg.senderId == currentUser.id + val author = threadState.messageAuthors[msg.senderId] + val activity = author?.activity + val bubbleType = if (msg.type == "location") ChatBubbleType.Location else ChatBubbleType.Regular + val onLocClick: (() -> Unit)? = if (msg.type == "location") { + { + val parts = msg.text.removePrefix("Ubicación: ").split(",") + if (parts.size == 2) { + val lat = parts[0].trim().toDoubleOrNull() + val lng = parts[1].trim().toDoubleOrNull() + if (lat != null && lng != null) { + placesViewModel.focusOnLocation(com.google.android.gms.maps.model.LatLng(lat, lng)) + controller.navigate(AppScreens.Map.name) + } + } + } + } else null + + val displayName = author?.name?.takeIf { it.isNotBlank() } ?: msg.senderName + ChatBubble( + message = msg.text, + mediaUrl = msg.mediaUrl, + mediaType = msg.type, + isUserMessage = isMine, + senderName = if (!isMine && isGroup && displayName.isNotBlank()) displayName else null, + senderActivity = if (!isMine && isGroup) activity else null, + type = bubbleType, + onLocationClick = onLocClick + ) + } + } + } } } + + diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt new file mode 100644 index 0000000..f2d264b --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/friends/FriendsScreen.kt @@ -0,0 +1,152 @@ +package com.appnotresponding.rumbo.ui.screens.friends + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.appnotresponding.rumbo.models.sampleUser +import com.appnotresponding.rumbo.navigation.AppScreens +import com.appnotresponding.rumbo.ui.components.atoms.RumboTextField +import com.appnotresponding.rumbo.ui.components.molecules.friends.UserSearchResultItem +import com.appnotresponding.rumbo.ui.components.molecules.friends.FriendRequestItem +import com.appnotresponding.rumbo.ui.components.organisms.friends.FriendsList +import com.appnotresponding.rumbo.ui.templates.FriendsTemplate +import com.appnotresponding.rumbo.ui.viewModel.ChatViewModel +import com.appnotresponding.rumbo.ui.viewModel.FriendsViewModel +import com.appnotresponding.rumbo.ui.viewModel.UserViewModel +import com.google.firebase.auth.FirebaseAuth + +@Composable +fun FriendsScreen( + controller: NavHostController, + userViewModel: UserViewModel, + friendsViewModel: FriendsViewModel, + chatViewModel: ChatViewModel +) { + val userState by userViewModel.currentUserState.collectAsState() + val currentUser = userState ?: sampleUser.copy(name = "Cargando...") + val friendsState by friendsViewModel.uiState.collectAsState() + val myUid = FirebaseAuth.getInstance().currentUser?.uid ?: "" + + var searchQuery by remember { mutableStateOf("") } + + FriendsTemplate( + currentUser = currentUser, + controller = controller + ) { + Column(modifier = Modifier.fillMaxSize()) { + RumboTextField( + modifier = Modifier.fillMaxWidth(), + value = searchQuery, + onValueChange = { + searchQuery = it + if (it.isBlank()) { + friendsViewModel.clearSearch() + } else { + friendsViewModel.searchUserByName(it) + } + }, + placeholder = "Buscar por nombre...", + label = "Buscar usuarios" + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (searchQuery.isNotBlank()) { + if (friendsState.isSearching) { + Text( + text = "Buscando...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else if (friendsState.searchError != null && friendsState.searchResults.isEmpty()) { + Text( + text = friendsState.searchError!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(bottom = 100.dp) + ) { + items(friendsState.searchResults) { user -> + UserSearchResultItem( + user = user, + isAlreadyFriend = friendsState.friendIds.contains(user.id), + isPending = friendsState.sentRequestIds.contains(user.id), + onAddClick = { friendsViewModel.addFriend(user.id) } + ) + } + } + } + } else { + if (friendsState.pendingRequests.isNotEmpty()) { + Text( + text = "Solicitudes de amistad", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp) + ) { + items(friendsState.pendingRequests) { requestUser -> + FriendRequestItem( + user = requestUser, + onAcceptClick = { friendsViewModel.acceptFriendRequest(requestUser.id) }, + onDeclineClick = { friendsViewModel.declineFriendRequest(requestUser.id) } + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + + Text( + text = "Mis amigos", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + if (friendsState.friends.isEmpty()) { + Text( + text = "Aún no tienes amigos agregados.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + FriendsList( + friends = friendsState.friends, + onFriendClick = { friend -> + val chatId = chatViewModel.getOrCreateDirectChatId(myUid, friend.id) + chatViewModel.selectDirectChat( + chatId = chatId, + chatTitle = friend.name, + photoUrl = friend.profilePictureUrl + ) + controller.navigate(AppScreens.ChatThread.name) + } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/map/MapScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/map/MapScreen.kt index fc5f2cb..f6ad0c3 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/map/MapScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/map/MapScreen.kt @@ -12,12 +12,15 @@ import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel import com.appnotresponding.rumbo.ui.viewModel.UserViewModel +import com.appnotresponding.rumbo.ui.viewModel.FriendsViewModel + @Composable fun MapScreen( controller: NavHostController, placesViewModel: PlacesViewModel, locationViewModel: UserLocationViewModel, - userViewModel: UserViewModel + userViewModel: UserViewModel, + friendsViewModel: FriendsViewModel ) { val userState by userViewModel.currentUserState.collectAsState() val user = userState ?: sampleUser.copy(name = "Cargando...") @@ -29,6 +32,7 @@ fun MapScreen( popUpTo(controller.graph.startDestinationId) { inclusive = true } launchSingleTop = true } - }, placesViewModel = placesViewModel, locationViewModel = locationViewModel + }, placesViewModel = placesViewModel, locationViewModel = locationViewModel, + userViewModel = userViewModel, friendsViewModel = friendsViewModel ) } \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt index ed8d275..2331917 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt @@ -3,6 +3,7 @@ package com.appnotresponding.rumbo.ui.templates import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable @@ -11,34 +12,57 @@ import androidx.compose.ui.unit.dp import com.appnotresponding.rumbo.models.User import com.appnotresponding.rumbo.ui.components.molecules.chat.MessageComposer import com.appnotresponding.rumbo.ui.components.organisms.common.ChatTopBar - + @Composable fun ChatThreadTemplate( modifier: Modifier = Modifier, chatTitle: String, chatSubtitle: String, chatAvatarUser: User, + isGroup: Boolean = false, + isMuted: Boolean = false, + onMuteClick: (() -> Unit)? = null, + onLeaveClick: (() -> Unit)? = null, + onBackClick: (() -> Unit)? = null, messageInputValue: String = "", onMessageInputValueChange: (String) -> Unit = {}, onSendClick: () -> Unit = {}, + onImageClick: () -> Unit = {}, + onLocationClick: () -> Unit = {}, + onMicClick: () -> Unit = {}, content: @Composable () -> Unit ) { Scaffold(contentWindowInsets = WindowInsets(0), topBar = { - ChatTopBar(u = chatAvatarUser, activity = chatSubtitle) + ChatTopBar( + u = chatAvatarUser, + activity = chatSubtitle, + isGroup = isGroup, + isMuted = isMuted, + onMuteClick = onMuteClick, + onLeaveClick = onLeaveClick, + onBackClick = onBackClick + ) }, bottomBar = { - Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { + Box( + modifier = Modifier + .navigationBarsPadding() + .padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 12.dp) + ) { MessageComposer( value = messageInputValue, onValueChange = onMessageInputValueChange, - onSendClick = onSendClick + onSendClick = onSendClick, + onImageClick = onImageClick, + onLocationClick = onLocationClick, + onMicClick = onMicClick ) } }) { paddingValues -> Box( modifier = modifier .fillMaxSize() - .padding(bottom = paddingValues.calculateBottomPadding()) - .padding(horizontal = 8.dp) + .padding(paddingValues) + .padding(horizontal = 16.dp) ) { content() } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt new file mode 100644 index 0000000..232d926 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt @@ -0,0 +1,59 @@ +package com.appnotresponding.rumbo.ui.templates + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.appnotresponding.rumbo.models.User +import com.appnotresponding.rumbo.ui.components.organisms.common.MainTopBar +import com.appnotresponding.rumbo.ui.components.organisms.common.Nav + +@Composable +fun FriendsTemplate( + currentUser: User, + onProfileClick: () -> Unit = {}, + controller: NavHostController, + content: @Composable () -> Unit +) { + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { MainTopBar(u = currentUser, onProfileClick = onProfileClick) }, + bottomBar = { Nav(controller) } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)) { + Text( + text = "Amigos", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Busca y conecta con otros viajeros", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Box( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) { + content() + } + } + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt index a6d1fa7..501365e 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt @@ -15,11 +15,17 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.IconButton +import androidx.compose.material3.Icon +import androidx.compose.ui.res.painterResource +import com.appnotresponding.rumbo.ui.viewModel.UserViewModel +import com.appnotresponding.rumbo.ui.viewModel.FriendsViewModel import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -33,6 +39,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap @@ -110,7 +117,9 @@ fun MapTemplate( viewModel: MapViewModel = viewModel(), dropNoteViewModel: DropNoteViewModel = viewModel(), placesViewModel: PlacesViewModel, - locationViewModel: UserLocationViewModel + locationViewModel: UserLocationViewModel, + userViewModel: UserViewModel, + friendsViewModel: FriendsViewModel ) { Log.d("RECOMPOSE", "MapTemplate recomposed") @@ -120,6 +129,7 @@ fun MapTemplate( val dropNoteState by dropNoteViewModel.uiState.collectAsState() val userLocationState by locationViewModel.uiState.collectAsState() val placesState by placesViewModel.uiState.collectAsState() + val friendsState by friendsViewModel.uiState.collectAsState() var popupStateDNComposer by remember { mutableStateOf(false) } var popupStateReview by remember { mutableStateOf(false) } @@ -228,6 +238,22 @@ fun MapTemplate( currentMapStyle = if (isDarkTheme) MapColorScheme.DARK else MapColorScheme.LIGHT } + LaunchedEffect(placesState.selectedPlace) { + val place = placesState.selectedPlace + if (place != null) { + userViewModel.updateActivity("Rumbo al ${place.name}") + } else { + userViewModel.updateActivity(null) + } + } + + LaunchedEffect(placesState.focusLocation) { + placesState.focusLocation?.let { latLng -> + cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 16f) + placesViewModel.clearFocusLocation() + } + } + Scaffold( contentWindowInsets = WindowInsets(0), @@ -235,7 +261,8 @@ fun MapTemplate( floatingActionButton = { Column( modifier = Modifier - .width(45.dp), + .width(56.dp) + .padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom) ) { if (permission.status.isGranted) { @@ -261,7 +288,31 @@ fun MapTemplate( locationState.requestPermission() } } - + Box( + modifier = Modifier + .aspectRatio(1f) + .clip(CircleShape) + .background( + if (user.sharingLocation) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant + ), contentAlignment = Alignment.Center + ) { + IconButton(onClick = { + Log.d("MapTemplate", "Eye button clicked! Current state sharingLocation=${user.sharingLocation}, toggling to ${!user.sharingLocation}") + userViewModel.toggleLocationSharing(!user.sharingLocation) + }) { + Icon( + painter = painterResource( + if (user.sharingLocation) R.drawable.ic_eye_open + else R.drawable.ic_eye_crossed + ), + contentDescription = "Compartir ubicación", + tint = if (user.sharingLocation) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + } } } }, @@ -303,6 +354,20 @@ fun MapTemplate( ){ Avatar(user = user, modifier = Modifier.border(1.dp, Color.White, CircleShape)) } + friendsState.friends.forEach { friend -> + if (friend.sharingLocation && (friend.latitude != 0.0 || friend.longitude != 0.0)) { + val friendPos = LatLng(friend.latitude, friend.longitude) + MarkerComposable( + state = rememberUpdatedMarkerState(friendPos), + title = "${friend.name} ${friend.lastname}" + ) { + Avatar( + user = friend, + modifier = Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape) + ) + } + } + } Marker( state = rememberUpdatedMarkerState(state.additionalMarker.position), title = state.additionalMarker.title, diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt new file mode 100644 index 0000000..920aa19 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt @@ -0,0 +1,272 @@ +package com.appnotresponding.rumbo.ui.viewModel + +import androidx.lifecycle.ViewModel +import com.appnotresponding.rumbo.models.ChatMessage +import com.appnotresponding.rumbo.models.User +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.database.DataSnapshot +import com.google.firebase.storage.FirebaseStorage +import android.net.Uri +import com.google.firebase.database.DatabaseError +import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.ValueEventListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class ChatThreadState( + val messages: List = emptyList(), + val isSending: Boolean = false, + val messageAuthors: Map = emptyMap() +) + +class ChatThreadViewModel : ViewModel() { + + private val auth = FirebaseAuth.getInstance() + private val db = FirebaseDatabase.getInstance() + private val storage = FirebaseStorage.getInstance() + + private val _uiState = MutableStateFlow(ChatThreadState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var currentListener: ValueEventListener? = null + private var currentRef: com.google.firebase.database.DatabaseReference? = null + + private val dbUsers = db.getReference("users") + private val userCache = mutableMapOf() + private val userListeners = mutableMapOf() + + private fun clearUserListeners() { + userListeners.forEach { (senderId, listener) -> + dbUsers.child(senderId).removeEventListener(listener) + } + userListeners.clear() + userCache.clear() + } + + private fun resolveUsersAndEmit(rawMessages: List, extraUid: String? = null) { + val uniqueSenderIds = (rawMessages.map { it.senderId } + listOfNotNull(extraUid)).distinct() + + fun pushState() { + val authorsMap = userCache.toMap() + _uiState.update { + it.copy(messages = rawMessages, messageAuthors = authorsMap) + } + } + + uniqueSenderIds.forEach { senderId -> + if (!userCache.containsKey(senderId) && !userListeners.containsKey(senderId)) { + val listener = object : ValueEventListener { + override fun onDataChange(userSnapshot: DataSnapshot) { + val user = userSnapshot.getValue(User::class.java) + if (user != null) { + userCache[senderId] = user + } + pushState() + } + + override fun onCancelled(error: DatabaseError) {} + } + userListeners[senderId] = listener + dbUsers.child(senderId).addValueEventListener(listener) + } + } + + pushState() + } + + fun listenToMessages(chatId: String) { + clearUserListeners() + currentRef?.let { ref -> + currentListener?.let { ref.removeEventListener(it) } + } + + val ref = db.getReference("messages").child(chatId) + currentRef = ref + + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val messages = mutableListOf() + for (child in snapshot.children) { + val msg = child.getValue(ChatMessage::class.java) ?: continue + messages.add(msg) + } + val parts = chatId.split("_") + val myUid = auth.currentUser?.uid ?: "" + val otherUid = parts.firstOrNull { it != myUid } + resolveUsersAndEmit(messages.sortedBy { m -> m.timestamp }, otherUid) + } + + override fun onCancelled(error: DatabaseError) {} + } + currentListener = listener + ref.addValueEventListener(listener) + } + + fun listenToGroupMessages(placeId: String) { + clearUserListeners() + currentRef?.let { ref -> + currentListener?.let { ref.removeEventListener(it) } + } + + val ref = db.getReference("groupMessages").child(placeId) + currentRef = ref + + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val messages = mutableListOf() + for (child in snapshot.children) { + val msg = child.getValue(ChatMessage::class.java) ?: continue + messages.add(msg) + } + resolveUsersAndEmit(messages.sortedBy { m -> m.timestamp }) + } + + override fun onCancelled(error: DatabaseError) {} + } + currentListener = listener + ref.addValueEventListener(listener) + } + + fun sendMessage(chatId: String, text: String) { + val myUid = auth.currentUser?.uid ?: return + if (text.isBlank()) return + + _uiState.update { it.copy(isSending = true) } + + val participants = chatId.split("_") + if (participants.size == 2) { + db.getReference("chats").child(chatId).child("participants").setValue(participants) + } + + val ref = db.getReference("messages").child(chatId) + val msgId = ref.push().key ?: return + val msg = ChatMessage( + id = msgId, + senderId = myUid, + text = text, + timestamp = System.currentTimeMillis() + ) + ref.child(msgId).setValue(msg).addOnSuccessListener { + db.getReference("chats").child(chatId).child("lastMessage").setValue(text) + db.getReference("chats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + _uiState.update { it.copy(isSending = false) } + }.addOnFailureListener { + _uiState.update { it.copy(isSending = false) } + } + } + + fun sendGroupMessage(placeId: String, senderName: String, text: String) { + val myUid = auth.currentUser?.uid ?: return + if (text.isBlank()) return + + _uiState.update { it.copy(isSending = true) } + val ref = db.getReference("groupMessages").child(placeId) + val msgId = ref.push().key ?: return + val msg = ChatMessage( + id = msgId, + senderId = myUid, + senderName = senderName, + text = text, + timestamp = System.currentTimeMillis() + ) + ref.child(msgId).setValue(msg).addOnSuccessListener { + db.getReference("groupChats").child(placeId).child("lastMessage").setValue("${senderName}: $text") + db.getReference("groupChats").child(placeId).child("lastMessageTimestamp").setValue(msg.timestamp) + _uiState.update { it.copy(isSending = false) } + }.addOnFailureListener { + _uiState.update { it.copy(isSending = false) } + } + } + + fun createDirectChatIfNeeded(chatId: String, myUid: String, friendUid: String) { + val ref = db.getReference("chats").child(chatId) + ref.child("participants").setValue(listOf(myUid, friendUid)) + } + + fun sendLocationMessage(chatId: String, senderName: String?, latitude: Double, longitude: Double, isGroup: Boolean) { + val myUid = auth.currentUser?.uid ?: return + _uiState.update { it.copy(isSending = true) } + + val ref = if (isGroup) db.getReference("groupMessages").child(chatId) else db.getReference("messages").child(chatId) + val msgId = ref.push().key ?: return + val textValue = "Ubicación: $latitude, $longitude" + val msg = ChatMessage( + id = msgId, + senderId = myUid, + senderName = senderName ?: "", + text = textValue, + timestamp = System.currentTimeMillis(), + type = "location" + ) + + ref.child(msgId).setValue(msg).addOnSuccessListener { + if (isGroup) { + db.getReference("groupChats").child(chatId).child("lastMessage").setValue("${senderName ?: ""}: 📍 Ubicación") + db.getReference("groupChats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + } else { + val parts = chatId.split("_") + if (parts.size == 2) { + val friendUid = if (parts[0] == myUid) parts[1] else parts[0] + createDirectChatIfNeeded(chatId, myUid, friendUid) + } + db.getReference("chats").child(chatId).child("lastMessage").setValue("📍 Ubicación") + db.getReference("chats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + } + _uiState.update { it.copy(isSending = false) } + }.addOnFailureListener { + _uiState.update { it.copy(isSending = false) } + } + } + + fun sendMediaMessage(chatId: String, senderName: String?, uri: Uri, isGroup: Boolean, mediaType: String) { + val myUid = auth.currentUser?.uid ?: return + _uiState.update { it.copy(isSending = true) } + + val storageRef = storage.reference.child("chat_media").child(chatId).child("${System.currentTimeMillis()}_${myUid}") + storageRef.putFile(uri).addOnSuccessListener { + storageRef.downloadUrl.addOnSuccessListener { downloadUrl -> + val ref = if (isGroup) db.getReference("groupMessages").child(chatId) else db.getReference("messages").child(chatId) + val msgId = ref.push().key ?: return@addOnSuccessListener + val msg = ChatMessage( + id = msgId, + senderId = myUid, + senderName = senderName ?: "", + text = if (mediaType == "image") "📷 Imagen" else "🎤 Nota de voz", + timestamp = System.currentTimeMillis(), + type = mediaType, + mediaUrl = downloadUrl.toString() + ) + + ref.child(msgId).setValue(msg).addOnSuccessListener { + if (isGroup) { + db.getReference("groupChats").child(chatId).child("lastMessage").setValue("${senderName ?: ""}: ${msg.text}") + db.getReference("groupChats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + } else { + val parts = chatId.split("_") + if (parts.size == 2) { + val friendUid = if (parts[0] == myUid) parts[1] else parts[0] + createDirectChatIfNeeded(chatId, myUid, friendUid) + } + db.getReference("chats").child(chatId).child("lastMessage").setValue(msg.text) + db.getReference("chats").child(chatId).child("lastMessageTimestamp").setValue(msg.timestamp) + } + _uiState.update { state -> state.copy(isSending = false) } + }.addOnFailureListener { + _uiState.update { state -> state.copy(isSending = false) } + } + } + }.addOnFailureListener { + _uiState.update { it.copy(isSending = false) } + } + } + + override fun onCleared() { + super.onCleared() + clearUserListeners() + currentRef?.let { ref -> + currentListener?.let { ref.removeEventListener(it) } + } + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt new file mode 100644 index 0000000..7252b89 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt @@ -0,0 +1,298 @@ +package com.appnotresponding.rumbo.ui.viewModel + +import androidx.lifecycle.ViewModel +import com.appnotresponding.rumbo.models.ChatConversation +import com.appnotresponding.rumbo.models.GroupChat +import com.appnotresponding.rumbo.models.Place +import com.appnotresponding.rumbo.models.User +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.database.DataSnapshot +import com.google.firebase.database.DatabaseError +import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.ValueEventListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class ChatListState( + val directChats: List = emptyList(), + val groupChats: List = emptyList(), + val selectedChatId: String = "", + val selectedChatTitle: String = "", + val selectedChatPhoto: String? = null, + val isGroupChat: Boolean = false +) + +class ChatViewModel : ViewModel() { + + private val auth = FirebaseAuth.getInstance() + private val db = FirebaseDatabase.getInstance() + private val dbChats = db.getReference("chats") + private val dbUsers = db.getReference("users") + private val dbGroupChats = db.getReference("groupChats") + + private val _uiState = MutableStateFlow(ChatListState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val chatListeners = mutableListOf>() + private val groupListeners = mutableMapOf() + private var authListener: FirebaseAuth.AuthStateListener? = null + + private val userListeners = mutableMapOf() + private val resolvedUsers = mutableMapOf() + private var latestChatsSnapshot: DataSnapshot? = null + + init { + authListener = FirebaseAuth.AuthStateListener { firebaseAuth -> + val uid = firebaseAuth.currentUser?.uid + clearAllListeners() + if (uid != null) { + listenToDirectChats(uid) + } else { + _uiState.update { ChatListState() } + } + } + auth.addAuthStateListener(authListener!!) + } + + private fun setupUserListener(otherUid: String, myUid: String) { + if (userListeners.containsKey(otherUid)) return + val userListener = object : ValueEventListener { + override fun onDataChange(userSnapshot: DataSnapshot) { + val user = userSnapshot.getValue(User::class.java) + if (user != null) { + resolvedUsers[otherUid] = user + rebuildConversationsList(myUid) + } + } + override fun onCancelled(error: DatabaseError) {} + } + userListeners[otherUid] = userListener + dbUsers.child(otherUid).addValueEventListener(userListener) + } + + private fun rebuildConversationsList(myUid: String) { + val snapshot = latestChatsSnapshot ?: return + val conversations = mutableListOf() + val children = snapshot.children.toList() + + if (children.isEmpty()) { + _uiState.update { it.copy(directChats = emptyList()) } + return + } + + var pending = children.size + if (pending == 0) { + _uiState.update { it.copy(directChats = emptyList()) } + return + } + + for (child in children) { + val chatId = child.key + if (chatId == null) { + pending-- + if (pending == 0) { + _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } + } + continue + } + val participants = child.child("participants").children.map { it.value as? String ?: "" } + if (!participants.contains(myUid)) { + pending-- + if (pending == 0) { + _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } + } + continue + } + val otherUid = participants.firstOrNull { it != myUid } + if (otherUid == null) { + pending-- + if (pending == 0) { + _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } + } + continue + } + val lastMessage = child.child("lastMessage").value as? String ?: "" + val lastTimestamp = child.child("lastMessageTimestamp").value as? Long ?: 0L + + db.getReference("friendships").child(myUid).child(otherUid).get().addOnSuccessListener { friendshipSnap -> + val areFriends = friendshipSnap.exists() && friendshipSnap.value == true + if (!areFriends) { + pending-- + if (pending == 0) { + _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } + } + return@addOnSuccessListener + } + + val user = resolvedUsers[otherUid] + if (user != null) { + conversations.add( + ChatConversation( + chatId = chatId, + otherUserId = otherUid, + otherUserName = user.name, + otherUserPhotoUrl = user.profilePictureUrl, + otherUserActivity = user.activity, + lastMessage = lastMessage, + lastMessageTimestamp = lastTimestamp + ) + ) + } else { + setupUserListener(otherUid, myUid) + } + pending-- + if (pending == 0) { + _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } + } + }.addOnFailureListener { + pending-- + if (pending == 0) { + _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } + } + } + } + } + + private fun listenToDirectChats(myUid: String) { + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + latestChatsSnapshot = snapshot + rebuildConversationsList(myUid) + } + + override fun onCancelled(error: DatabaseError) {} + } + dbChats.addValueEventListener(listener) + chatListeners.add(Pair(dbChats, listener)) + } + + fun listenToGroupChats(itinerary: List) { + val myUid = auth.currentUser?.uid ?: return + val currentPlaceIds = itinerary.map { it.id }.toSet() + + val toRemove = groupListeners.keys - currentPlaceIds + for (placeId in toRemove) { + val listener = groupListeners[placeId] + if (listener != null) { + dbGroupChats.child(placeId).removeEventListener(listener) + } + groupListeners.remove(placeId) + } + + _uiState.update { state -> + state.copy(groupChats = state.groupChats.filter { it.placeId in currentPlaceIds }) + } + + for (place in itinerary) { + if (groupListeners.containsKey(place.id)) continue + + val ref = dbGroupChats.child(place.id) + ref.child("participants").child(myUid).setValue(true) + ref.child("placeId").setValue(place.id) + ref.child("placeName").setValue(place.name) + + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val placeId = snapshot.child("placeId").value as? String ?: return@onDataChange + val placeName = snapshot.child("placeName").value as? String ?: "" + val lastMessage = snapshot.child("lastMessage").value as? String ?: "" + val lastTimestamp = snapshot.child("lastMessageTimestamp").value as? Long ?: 0L + val mutedByMap = mutableMapOf() + for (muteChild in snapshot.child("mutedBy").children) { + val muteKey = muteChild.key ?: continue + mutedByMap[muteKey] = muteChild.value as? Boolean ?: false + } + + val group = GroupChat( + placeId = placeId, + placeName = placeName, + lastMessage = lastMessage, + lastMessageTimestamp = lastTimestamp, + mutedBy = mutedByMap + ) + + val current = _uiState.value.groupChats.toMutableList() + val idx = current.indexOfFirst { it.placeId == placeId } + if (idx >= 0) current[idx] = group else current.add(group) + _uiState.update { it.copy(groupChats = current.toList()) } + } + + override fun onCancelled(error: DatabaseError) {} + } + ref.addValueEventListener(listener) + groupListeners[place.id] = listener + } + } + + fun selectDirectChat(chatId: String, chatTitle: String, photoUrl: String?) { + _uiState.update { + it.copy( + selectedChatId = chatId, + selectedChatTitle = chatTitle, + selectedChatPhoto = photoUrl, + isGroupChat = false + ) + } + } + + fun selectGroupChat(placeId: String, placeName: String) { + _uiState.update { + it.copy( + selectedChatId = placeId, + selectedChatTitle = placeName, + selectedChatPhoto = null, + isGroupChat = true + ) + } + } + + fun getOrCreateDirectChatId(myUid: String, friendUid: String): String { + val sorted = listOf(myUid, friendUid).sorted() + return "${sorted[0]}_${sorted[1]}" + } + + fun leaveGroup(placeId: String) { + val myUid = auth.currentUser?.uid ?: return + dbGroupChats.child(placeId).child("participants").child(myUid).removeValue() + val current = _uiState.value.groupChats.toMutableList() + current.removeAll { it.placeId == placeId } + _uiState.update { it.copy(groupChats = current.toList()) } + } + + fun muteGroup(placeId: String) { + val myUid = auth.currentUser?.uid ?: return + dbGroupChats.child(placeId).child("mutedBy").child(myUid).setValue(true) + } + + fun unmuteGroup(placeId: String) { + val myUid = auth.currentUser?.uid ?: return + dbGroupChats.child(placeId).child("mutedBy").child(myUid).removeValue() + } + + private fun clearAllListeners() { + for ((ref, listener) in chatListeners) { + ref.removeEventListener(listener) + } + chatListeners.clear() + + for ((placeId, listener) in groupListeners) { + dbGroupChats.child(placeId).removeEventListener(listener) + } + groupListeners.clear() + + for ((otherUid, listener) in userListeners) { + dbUsers.child(otherUid).removeEventListener(listener) + } + userListeners.clear() + resolvedUsers.clear() + latestChatsSnapshot = null + } + + override fun onCleared() { + super.onCleared() + authListener?.let { auth.removeAuthStateListener(it) } + clearAllListeners() + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt new file mode 100644 index 0000000..bb688f2 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt @@ -0,0 +1,248 @@ +package com.appnotresponding.rumbo.ui.viewModel + +import androidx.lifecycle.ViewModel +import com.appnotresponding.rumbo.models.User +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.database.DataSnapshot +import com.google.firebase.database.DatabaseError +import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.ValueEventListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class FriendsState( + val friends: List = emptyList(), + val searchResults: List = emptyList(), + val isSearching: Boolean = false, + val searchError: String? = null, + val friendIds: Set = emptySet(), + val pendingRequests: List = emptyList(), + val sentRequestIds: Set = emptySet() +) + +class FriendsViewModel : ViewModel() { + + private val auth = FirebaseAuth.getInstance() + private val dbUsers = FirebaseDatabase.getInstance().getReference("users") + private val dbFriendships = FirebaseDatabase.getInstance().getReference("friendships") + private val dbRequests = FirebaseDatabase.getInstance().getReference("friend_requests") + private val dbSentRequests = FirebaseDatabase.getInstance().getReference("friend_requests_sent") + + private val _uiState = MutableStateFlow(FriendsState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var friendsListener: ValueEventListener? = null + private var requestsListener: ValueEventListener? = null + private var sentRequestsListener: ValueEventListener? = null + private var authListener: FirebaseAuth.AuthStateListener? = null + + init { + authListener = FirebaseAuth.AuthStateListener { firebaseAuth -> + val uid = firebaseAuth.currentUser?.uid + clearAllListeners() + if (uid != null) { + listenToFriends(uid) + listenToRequests(uid) + } else { + _uiState.update { FriendsState() } + } + } + auth.addAuthStateListener(authListener!!) + } + + private fun listenToFriends(myUid: String) { + friendsListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val friendIds = mutableSetOf() + for (child in snapshot.children) { + friendIds.add(child.key ?: continue) + } + _uiState.update { it.copy(friendIds = friendIds) } + loadFriendUsers(friendIds.toList()) + } + + override fun onCancelled(error: DatabaseError) {} + } + dbFriendships.child(myUid).addValueEventListener(friendsListener!!) + } + + private val friendListeners = mutableMapOf() + + private fun loadFriendUsers(friendIds: List) { + friendListeners.forEach { (friendId, listener) -> + dbUsers.child(friendId).removeEventListener(listener) + } + friendListeners.clear() + + if (friendIds.isEmpty()) { + _uiState.update { it.copy(friends = emptyList()) } + return + } + + val friendsMap = mutableMapOf() + for (friendId in friendIds) { + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val user = snapshot.getValue(User::class.java) + if (user != null) { + friendsMap[friendId] = user + _uiState.update { it.copy(friends = friendsMap.values.toList()) } + } + } + + override fun onCancelled(error: DatabaseError) {} + } + friendListeners[friendId] = listener + dbUsers.child(friendId).addValueEventListener(listener) + } + } + + private fun listenToRequests(myUid: String) { + requestsListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val senderIds = snapshot.children.mapNotNull { it.key } + loadRequestUsers(senderIds) + } + override fun onCancelled(error: DatabaseError) {} + } + dbRequests.child(myUid).addValueEventListener(requestsListener!!) + + sentRequestsListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val sentIds = snapshot.children.mapNotNull { it.key }.toSet() + _uiState.update { it.copy(sentRequestIds = sentIds) } + } + override fun onCancelled(error: DatabaseError) {} + } + dbSentRequests.child(myUid).addValueEventListener(sentRequestsListener!!) + } + + private val requestUsersMap = mutableMapOf() + private val requestListeners = mutableMapOf() + + private fun loadRequestUsers(senderIds: List) { + requestListeners.forEach { (senderId, listener) -> + dbUsers.child(senderId).removeEventListener(listener) + } + requestListeners.clear() + + if (senderIds.isEmpty()) { + requestUsersMap.clear() + _uiState.update { it.copy(pendingRequests = emptyList()) } + return + } + + requestUsersMap.keys.retainAll(senderIds) + _uiState.update { it.copy(pendingRequests = requestUsersMap.values.toList()) } + + for (senderId in senderIds) { + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val user = snapshot.getValue(User::class.java) + if (user != null) { + requestUsersMap[senderId] = user + _uiState.update { it.copy(pendingRequests = requestUsersMap.values.toList()) } + } + } + override fun onCancelled(error: DatabaseError) {} + } + requestListeners[senderId] = listener + dbUsers.child(senderId).addValueEventListener(listener) + } + } + + fun searchUserByName(query: String) { + val myUid = auth.currentUser?.uid ?: return + if (query.isBlank()) { + _uiState.update { it.copy(searchResults = emptyList(), searchError = null) } + return + } + _uiState.update { it.copy(isSearching = true, searchError = null) } + dbUsers.get().addOnSuccessListener { snapshot -> + val results = mutableListOf() + for (child in snapshot.children) { + val user = child.getValue(User::class.java) ?: continue + val fullName = "${user.name} ${user.lastname}".lowercase().trim() + if (user.id != myUid && fullName.contains(query.lowercase().trim())) { + results.add(user) + } + } + _uiState.update { + it.copy( + searchResults = results, + isSearching = false, + searchError = if (results.isEmpty()) "No se encontraron usuarios" else null + ) + } + }.addOnFailureListener { + _uiState.update { s -> s.copy(isSearching = false, searchError = "Error al buscar") } + } + } + + fun addFriend(targetUid: String) { + val myUid = auth.currentUser?.uid ?: return + if (targetUid == myUid) return + + // Optimistic UI: update sentRequestIds immediately + _uiState.update { state -> + val updatedSent = state.sentRequestIds.toMutableSet().apply { add(targetUid) } + state.copy(sentRequestIds = updatedSent) + } + + dbRequests.child(targetUid).child(myUid).setValue(true) + dbSentRequests.child(myUid).child(targetUid).setValue(true) + } + + fun acceptFriendRequest(senderUid: String) { + val myUid = auth.currentUser?.uid ?: return + + // 1. Remove request + dbRequests.child(myUid).child(senderUid).removeValue() + dbSentRequests.child(senderUid).child(myUid).removeValue() + + // 2. Add mutual friendship + dbFriendships.child(myUid).child(senderUid).setValue(true) + dbFriendships.child(senderUid).child(myUid).setValue(true) + } + + fun declineFriendRequest(senderUid: String) { + val myUid = auth.currentUser?.uid ?: return + + // Remove request + dbRequests.child(myUid).child(senderUid).removeValue() + dbSentRequests.child(senderUid).child(myUid).removeValue() + } + + fun clearSearch() { + _uiState.update { it.copy(searchResults = emptyList(), searchError = null) } + } + + private fun clearAllListeners() { + val uid = auth.currentUser?.uid + if (uid != null) { + friendsListener?.let { dbFriendships.child(uid).removeEventListener(it) } + requestsListener?.let { dbRequests.child(uid).removeEventListener(it) } + sentRequestsListener?.let { dbSentRequests.child(uid).removeEventListener(it) } + } + friendListeners.forEach { (friendId, listener) -> + dbUsers.child(friendId).removeEventListener(listener) + } + friendListeners.clear() + requestListeners.forEach { (senderId, listener) -> + dbUsers.child(senderId).removeEventListener(listener) + } + requestListeners.clear() + + friendsListener = null + requestsListener = null + sentRequestsListener = null + } + + override fun onCleared() { + super.onCleared() + authListener?.let { auth.removeAuthStateListener(it) } + clearAllListeners() + } +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt index c7742b9..b49c485 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt @@ -37,4 +37,12 @@ class PlacesViewModel : ViewModel() { fun clearForNavigation() { _uiState.update { it.copy(selectedPlace = null) } } + + fun focusOnLocation(latLng: com.google.android.gms.maps.model.LatLng) { + _uiState.update { it.copy(focusLocation = latLng) } + } + + fun clearFocusLocation() { + _uiState.update { it.copy(focusLocation = null) } + } } \ No newline at end of file diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt index eac0223..b89db5c 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt @@ -31,14 +31,54 @@ class UserViewModel : ViewModel() { } private fun fetchUserData(uid: String) { + android.util.Log.d("UserViewModel", "Starting ValueEventListener for uid: $uid") dbRef.child(uid).addValueEventListener(object : ValueEventListener { override fun onDataChange(snapshot: DataSnapshot) { - val user = snapshot.getValue(User::class.java) - _currentUserState.value = user + try { + val user = snapshot.getValue(User::class.java) + android.util.Log.d("UserViewModel", "fetchUserData success: user=${user?.name}, sharingLocation=${user?.sharingLocation}") + _currentUserState.value = user + } catch (e: Exception) { + android.util.Log.e("UserViewModel", "Error deserializing User object: ${e.message}", e) + } } override fun onCancelled(error: DatabaseError) { + android.util.Log.e("UserViewModel", "fetchUserData cancelled: ${error.message}") } }) } + + fun toggleLocationSharing(isSharing: Boolean) { + val uid = auth.currentUser?.uid + if (uid == null) { + android.util.Log.e("UserViewModel", "Cannot toggle location sharing: user not authenticated (uid is null)") + return + } + android.util.Log.d("UserViewModel", "Toggling location sharing to $isSharing for uid: $uid") + dbRef.child(uid).child("sharingLocation").setValue(isSharing) + .addOnSuccessListener { + android.util.Log.d("UserViewModel", "Location sharing successfully set to $isSharing in DB") + } + .addOnFailureListener { e -> + android.util.Log.e("UserViewModel", "Failed to set location sharing to $isSharing: ${e.message}", e) + } + } + + fun updateActivity(activity: String?) { + val uid = auth.currentUser?.uid + if (uid == null) { + android.util.Log.e("UserViewModel", "Cannot update activity: user not authenticated (uid is null)") + return + } + android.util.Log.d("UserViewModel", "Updating activity to $activity for uid: $uid") + dbRef.child(uid).child("activity").setValue(activity) + .addOnSuccessListener { + android.util.Log.d("UserViewModel", "Activity successfully set to $activity in DB") + } + .addOnFailureListener { e -> + android.util.Log.e("UserViewModel", "Failed to set activity to $activity: ${e.message}", e) + } + } } + From 0784ebd38d143aedccdfa1bab6a2ddbabdc76c07 Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Tue, 2 Jun 2026 10:15:38 -0500 Subject: [PATCH 29/49] [Feat]: Add user profile screen and visit tracking Introduces a dedicated profile screen allowing users to: - View and edit personal information (name, lastname, phone, profile picture). - Access an itinerary history, displaying places they have visited. - Review their created DropNotes. This includes a new `VisitedPlace` model and `ItineraryHistoryViewModel` to automatically track user visits to selected places when within proximity. Navigation is updated to route profile clicks to this new screen. --- .../rumbo/models/visitedPlace.kt | 12 + .../rumbo/navigation/navigation.kt | 42 +- .../ui/screens/itinerary/ItineraryScreen.kt | 7 +- .../rumbo/ui/screens/map/MapScreen.kt | 9 +- .../rumbo/ui/screens/plan/PlanScreen.kt | 9 +- .../rumbo/ui/screens/profile/ProfileScreen.kt | 83 ++++ .../rumbo/ui/templates/MapTemplate.kt | 95 ++-- .../rumbo/ui/templates/ProfileTemplate.kt | 460 ++++++++++++++++++ .../ui/viewModel/ItineraryHistoryViewModel.kt | 80 +++ .../rumbo/ui/viewModel/ProfileViewModel.kt | 184 +++++++ 10 files changed, 895 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/com/appnotresponding/rumbo/models/visitedPlace.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/screens/profile/ProfileScreen.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/templates/ProfileTemplate.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/ItineraryHistoryViewModel.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/ProfileViewModel.kt 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 526fd99..b271223 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt @@ -14,60 +14,54 @@ 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.PlacesViewModel -import androidx.lifecycle.ViewModel import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel - import com.appnotresponding.rumbo.ui.viewModel.UserViewModel val placesViewModel: PlacesViewModel = PlacesViewModel() -enum class AppScreens{ - Splash, - LogIn, - SignUp, - Map, - Chat, - ChatThread, - Plan, - Itinerary, - OnBoarding +enum class AppScreens { + Splash, LogIn, SignUp, Map, Chat, ChatThread, Plan, Itinerary, Profile, OnBoarding } @Composable fun Navigation( locationViewModel: UserLocationViewModel = viewModel(), userViewModel: UserViewModel = viewModel() -){ - val context = LocalContext.current +) { + LocalContext.current val navController = rememberNavController() - NavHost(navController=navController, startDestination = AppScreens.Splash.name){ - composable (route = AppScreens.Splash.name){ + NavHost(navController = navController, startDestination = AppScreens.Splash.name) { + composable(route = AppScreens.Splash.name) { SplashScreen(navController) } - composable(route = AppScreens.LogIn.name){ + composable(route = AppScreens.LogIn.name) { LogInScreen(navController) } - composable (route = AppScreens.SignUp.name){ + composable(route = AppScreens.SignUp.name) { SignUpScreen(navController) } - composable (route = AppScreens.Map.name) { + composable(route = AppScreens.Map.name) { MapScreen(navController, placesViewModel, locationViewModel, userViewModel) } - composable (route = AppScreens.Chat.name) { + composable(route = AppScreens.Chat.name) { ChatListScreen(navController, userViewModel) } - composable(route = AppScreens.ChatThread.name){ + composable(route = AppScreens.ChatThread.name) { ChatThreadScreen(navController) } - composable(route = AppScreens.Plan.name){ + composable(route = AppScreens.Plan.name) { PlanScreen(navController, placesViewModel, locationViewModel, userViewModel) } - composable(route = AppScreens.Itinerary.name){ + composable(route = AppScreens.Itinerary.name) { ItineraryScreen(navController, placesViewModel, userViewModel) } - composable(route = AppScreens.OnBoarding.name){ + composable(route = AppScreens.Profile.name) { + ProfileScreen(navController, userViewModel) + } + composable(route = AppScreens.OnBoarding.name) { OnBoardingScreen(navController) } } 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 40dd5e6..51c9e3d 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 @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation.NavHostController -import com.appnotresponding.rumbo.auth import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens import com.appnotresponding.rumbo.ui.templates.ItineraryTemplate @@ -21,11 +20,7 @@ fun ItineraryScreen( ItineraryTemplate( user = user, itineraryList = state.itinerary, controller = controller, onProfileClick = { - auth.signOut() - controller.navigate(AppScreens.Splash.name) { - popUpTo(controller.graph.startDestinationId) { inclusive = true } - launchSingleTop = true - } + controller.navigate(AppScreens.Profile.name) }, placesViewModel ) } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/map/MapScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/map/MapScreen.kt index fc5f2cb..14dce74 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 @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation.NavHostController -import com.appnotresponding.rumbo.auth import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens import com.appnotresponding.rumbo.ui.templates.MapTemplate @@ -24,11 +23,7 @@ fun MapScreen( MapTemplate( user = user, controller = controller, onProfileClick = { - auth.signOut() - controller.navigate(AppScreens.Splash.name) { - popUpTo(controller.graph.startDestinationId) { inclusive = true } - launchSingleTop = true - } + controller.navigate(AppScreens.Profile.name) }, placesViewModel = placesViewModel, locationViewModel = locationViewModel ) -} \ 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 aabd84b..a08a23d 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 @@ -6,7 +6,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavHostController -import com.appnotresponding.rumbo.auth import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens import com.appnotresponding.rumbo.ui.templates.PlanTemplate @@ -64,12 +63,8 @@ fun PlanScreen( placesList = placesState.availablePlaces, controller = controller, onProfileClick = { - auth.signOut() - controller.navigate(AppScreens.Splash.name) { - popUpTo(controller.graph.startDestinationId) { inclusive = true } - launchSingleTop = true - } + controller.navigate(AppScreens.Profile.name) }, 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..84ac8bd --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/profile/ProfileScreen.kt @@ -0,0 +1,83 @@ +package com.appnotresponding.rumbo.ui.screens.profile + +import android.net.Uri +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.viewModel.ProfileViewModel +import com.appnotresponding.rumbo.ui.viewModel.UserViewModel + +@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 = { + 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/templates/MapTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt index a6d1fa7..6f3a741 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 @@ -52,28 +52,27 @@ 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.roadManager import com.appnotresponding.rumbo.ui.components.atoms.Avatar -import com.appnotresponding.rumbo.ui.components.atoms.AvatarSize -import com.appnotresponding.rumbo.ui.components.atoms.UserProfileBubble 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.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.models.DropNote -import com.appnotresponding.rumbo.ui.components.molecules.map.DropNoteBubble 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 @@ -109,6 +108,7 @@ fun MapTemplate( onProfileClick: () -> Unit = {}, viewModel: MapViewModel = viewModel(), dropNoteViewModel: DropNoteViewModel = viewModel(), + itineraryHistoryViewModel: ItineraryHistoryViewModel = viewModel(), placesViewModel: PlacesViewModel, locationViewModel: UserLocationViewModel ) { @@ -128,14 +128,12 @@ fun MapTemplate( val locationState = rememberLocationManager() val mediaManager = rememberMediaHardwareManager() var noteText by remember { mutableStateOf("") } - val markerKey = remember(user.profilePictureUrl) { user.profilePictureUrl ?: "" } + 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 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() @@ -148,8 +146,7 @@ fun MapTemplate( position = CameraPosition.fromLatLngZoom(LatLng(latitude, longitude), 15f) } var currentMapStyle by remember { mutableIntStateOf(MapColorScheme.FOLLOW_SYSTEM) } - val mapId = - stringResource(R.string.map_id) + val mapId = stringResource(R.string.map_id) var permission = rememberPermissionState(locationPermission) var showButton by remember { mutableStateOf(false) } @@ -169,12 +166,11 @@ fun MapTemplate( } LaunchedEffect( - userLocationState.latitude, - userLocationState.longitude, - state.centerInUserFirstTime + userLocationState.latitude, userLocationState.longitude, state.centerInUserFirstTime ) { Log.d("RECOMPOSE", "Enntrando en launch") - val tieneUbicacionReal = userLocationState.latitude != 0.0 || userLocationState.longitude != 0.0 + val tieneUbicacionReal = + userLocationState.latitude != 0.0 || userLocationState.longitude != 0.0 if (tieneUbicacionReal) { viewModel.updateUserMarker(userLocationState.latitude, userLocationState.longitude) @@ -189,8 +185,7 @@ fun MapTemplate( } else if (tieneUbicacionReal && placesState.selectedPlace != null) { cameraPositionState.position = CameraPosition.fromLatLngZoom( LatLng( - placesState.selectedPlace!!.latitude, - placesState.selectedPlace!!.longitude + placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude ), 14f ) viewModel.updateCenterInUserFirstTime() @@ -228,14 +223,28 @@ fun MapTemplate( 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 + ) + } + Scaffold( contentWindowInsets = WindowInsets(0), topBar = { MainTopBar(user, onProfileClick = onProfileClick) }, floatingActionButton = { Column( - modifier = Modifier - .width(45.dp), + modifier = Modifier.width(45.dp), verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom) ) { if (permission.status.isGranted) { @@ -296,12 +305,14 @@ fun MapTemplate( MarkerComposable( state = rememberUpdatedMarkerState( LatLng( - userLocationState.latitude, - userLocationState.longitude + userLocationState.latitude, userLocationState.longitude ) ), title = "${user.name} (Tú)" - ){ - Avatar(user = user, modifier = Modifier.border(1.dp, Color.White, CircleShape)) + ) { + Avatar( + user = user, + modifier = Modifier.border(1.dp, Color.White, CircleShape) + ) } Marker( state = rememberUpdatedMarkerState(state.additionalMarker.position), @@ -310,13 +321,17 @@ fun MapTemplate( ) if (state.routePoints.isNotEmpty()) { Polyline( - points = state.routePoints, color = MaterialTheme.colorScheme.primary, width = 10f + points = state.routePoints, + color = MaterialTheme.colorScheme.primary, + width = 10f ) } if (state.userRouteVisible) { Polyline( - points = state.userRoutePoints, color = MaterialTheme.colorScheme.tertiary, width = 10f + points = state.userRoutePoints, + color = MaterialTheme.colorScheme.tertiary, + width = 10f ) } @@ -333,12 +348,9 @@ fun MapTemplate( selectedDropNote = note popupStateViewDN = true true - } - ) { + }) { DropNoteBubble( - modifier = Modifier.size(48.dp), - d = note, - author = author + modifier = Modifier.size(48.dp), d = note, author = author ) } } @@ -419,9 +431,12 @@ fun MapTemplate( 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 + 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, @@ -433,8 +448,7 @@ fun MapTemplate( noteText = "" mediaManager.clearImage() popupStateDNComposer = false - } - ) + }) } }, @@ -460,8 +474,7 @@ fun MapTemplate( onDismissRequest = { popupStateViewDN = false selectedDropNote = null - } - ) { + }) { Box( modifier = Modifier .fillMaxWidth() @@ -487,11 +500,9 @@ fun MapTemplate( }, onFailure = { error -> Log.e("MapTemplate", "Error al eliminar DropNote: $error") - } - ) - } - ) + }) + }) } } } -} \ 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..47899db --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ProfileTemplate.kt @@ -0,0 +1,460 @@ +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( + imageVector = when (menu) { + ProfileMenu.EditData -> Icons.Rounded.Edit + ProfileMenu.ItineraryHistory -> Icons.Rounded.Event + ProfileMenu.Memories -> Icons.Rounded.Image + }, 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(Icons.Rounded.ArrowBack, contentDescription = "Volver") + } + IconButton(onClick = onSignOut) { + Icon(Icons.Rounded.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( + imageVector = Icons.Rounded.PhotoCamera, + 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.forEach { (cityKey, days) -> + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + val cityName = days.values.flatten().firstOrNull()?.city ?: cityKey + Text( + text = cityName, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(12.dp)) + days.toSortedMap(compareByDescending { it }).forEach { (day, places) -> + Text( + text = day, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + places.forEach { place -> + VisitedPlaceRow(place) + Spacer(modifier = Modifier.height(10.dp)) + } + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + } + } + } + } + } +} + +@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/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..a4b6b2c --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/ProfileViewModel.kt @@ -0,0 +1,184 @@ +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 grouped = linkedMapOf>>() + snapshot.children.forEach { citySnapshot -> + val days = linkedMapOf>() + citySnapshot.children.forEach { daySnapshot -> + val places = daySnapshot.children.mapNotNull { + it.getValue(VisitedPlace::class.java) + }.sortedByDescending { it.visitedAt } + if (places.isNotEmpty()) { + days[daySnapshot.key ?: "Sin fecha"] = places + } + } + if (days.isNotEmpty()) { + grouped[citySnapshot.key ?: "Sin ciudad"] = days + } + } + _uiState.update { it.copy(itineraryHistory = grouped) } + } + + 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) + } + } + } +} From 76493238aa4daf367898017d6d228791ce7b0e00 Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Tue, 2 Jun 2026 12:28:08 -0500 Subject: [PATCH 30/49] [Feat]: Improve UI/UX and Contact Disctovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esta actualización introduce funcionalidades clave para una experiencia de chat más completa y una gestión de amigos mejorada: * **Presencia en tiempo real**: Implementa estado online/offline y última conexión para usuarios, visible en chats y lista de conversaciones. * **Recibos de lectura**: Muestra "Visto" en mensajes directos y "Visto por X" en grupos, junto con indicadores de mensajes no leídos. * **Funcionalidades de chat enriquecidas**: * Captura y envío de fotos directamente desde la cámara. * Interfaz mejorada para grabar y enviar notas de voz, incluyendo un estado de grabación. * Previsualización de imágenes a pantalla completa en la conversación. * Generación de previsualizaciones de mapas para ubicaciones compartidas. * Diseño de burbujas de chat actualizado con mejor diferenciación visual y gestión de secuencias de mensajes. * Separador "Nuevos mensajes" para destacar contenido no leído. * **Descubrimiento de amigos por contactos**: Permite buscar y añadir amigos de la agenda de contactos del dispositivo. * **Actualizaciones de permisos**: Añade permisos `CAMERA` y `READ_CONTACTS`. * **Refinamientos de UI/UX**: Diversos ajustes en la interfaz de usuario, incluyendo iconos y espaciado, para una apariencia más pulida. --- .gitignore | 2 + app/src/main/AndroidManifest.xml | 4 +- .../rumbo/models/chatConversation.kt | 5 +- .../rumbo/models/chatMessage.kt | 3 +- .../com/appnotresponding/rumbo/models/user.kt | 6 +- .../ui/components/atoms/UserProfileBubble.kt | 4 +- .../components/molecules/chat/ChatBubble.kt | 157 +++++++++++++----- .../components/molecules/chat/ChatListItem.kt | 52 ++++-- .../molecules/chat/MessageComposer.kt | 135 ++++++++++----- .../molecules/friends/FriendRequestItem.kt | 2 +- .../molecules/friends/UserSearchResultItem.kt | 24 ++- .../molecules/map/MapFloatingActions.kt | 2 +- .../ui/components/organisms/chat/ChatList.kt | 7 +- .../ui/components/organisms/common/Nav.kt | 83 ++++++++- .../ui/components/organisms/common/TopBar.kt | 11 +- .../organisms/map/DropNoteComposer.kt | 2 +- .../rumbo/ui/screens/auth/SignUpScreen.kt | 4 +- .../rumbo/ui/screens/chat/ChatListScreen.kt | 16 +- .../rumbo/ui/screens/chat/ChatThreadScreen.kt | 126 +++++++++++++- .../rumbo/ui/screens/friends/FriendsScreen.kt | 91 +++++++++- .../rumbo/ui/templates/ChatThreadTemplate.kt | 8 +- .../rumbo/ui/viewModel/chatThreadViewModel.kt | 99 ++++++++++- .../rumbo/ui/viewModel/chatViewModel.kt | 16 +- .../rumbo/ui/viewModel/friendsViewModel.kt | 44 +++++ .../rumbo/ui/viewModel/userViewModel.kt | 33 +++- app/src/main/res/drawable/ic_camera.xml | 12 ++ app/src/main/res/drawable/ic_cancel.xml | 12 ++ app/src/main/res/drawable/ic_minus.xml | 13 +- app/src/main/res/drawable/ic_recording.xml | 9 + .../main/res/drawable/outline_cancel_24.xml | 5 - 30 files changed, 826 insertions(+), 161 deletions(-) create mode 100644 app/src/main/res/drawable/ic_camera.xml create mode 100644 app/src/main/res/drawable/ic_cancel.xml create mode 100644 app/src/main/res/drawable/ic_recording.xml delete mode 100644 app/src/main/res/drawable/outline_cancel_24.xml diff --git a/.gitignore b/.gitignore index e5cbb64..80e26d7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ google-services.json # Android Profiling *.hprof + +.gradle-sandbox/ \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 593be21..0ace41c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + - \ 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 index 20b2a50..9b18473 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt @@ -6,8 +6,10 @@ data class ChatConversation( val otherUserName: String = "", val otherUserPhotoUrl: String? = null, val otherUserActivity: String? = null, + val isOtherUserOnline: Boolean = false, val lastMessage: String = "", - val lastMessageTimestamp: Long = 0 + val lastMessageTimestamp: Long = 0, + val unreadCount: Int = 0 ) data class GroupChat( @@ -15,5 +17,6 @@ data class GroupChat( val placeName: String = "", val lastMessage: String = "", val lastMessageTimestamp: Long = 0, + val unreadCount: Int = 0, val mutedBy: Map = emptyMap() ) diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt b/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt index fdf4914..9086cc3 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/chatMessage.kt @@ -8,5 +8,6 @@ data class ChatMessage( val timestamp: Long = 0, val type: String = "text", val placeId: String? = null, - val mediaUrl: String? = null + val mediaUrl: String? = null, + val seenBy: Map = emptyMap() ) 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 5b5c671..99c6817 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/user.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/user.kt @@ -12,7 +12,9 @@ data class User( val altitude: Double = 0.0, val profilePictureUrl: String? = null, val sharingLocation: Boolean = false, - val activity: String? = null + val activity: String? = null, + val isOnline: Boolean = false, + val lastSeenAt: Long = 0 ) val sampleUser = User( @@ -26,4 +28,4 @@ val sampleUser = User( altitude = 0.0, profilePictureUrl = "https://randomuser.me/api/portraits/men/1.jpg", sharingLocation = false -) \ No newline at end of file +) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt index ae5b1f5..7f126af 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/atoms/UserProfileBubble.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -33,6 +34,7 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.allowHardware +import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.models.User import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.ui.theme.RumboTheme @@ -95,7 +97,7 @@ fun UserProfileBubble( if (!user.profilePictureUrl.isNullOrEmpty() && imageLoadFailed) { Icon( modifier = Modifier.size(bubbleSize * 0.5f), - imageVector = Icons.Rounded.Person, + painter = painterResource(R.drawable.ic_user), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatBubble.kt index ad69ace..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 @@ -7,12 +7,15 @@ 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 @@ -27,7 +30,6 @@ 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 @@ -36,7 +38,7 @@ 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 @@ -46,6 +48,9 @@ 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) { @@ -92,6 +97,7 @@ enum class ChatBubbleType { */ @Composable fun ChatBubble( + modifier: Modifier = Modifier, message: String, mediaUrl: String? = null, mediaType: String? = null, @@ -100,6 +106,10 @@ fun ChatBubble( senderActivity: String? = null, type: ChatBubbleType = ChatBubbleType.Regular, place: Place? = null, + timestamp: Long = 0, + seenText: String? = null, + isLastInSequence: Boolean = true, + onMediaClick: ((String) -> Unit)? = null, onLocationClick: (() -> Unit)? = null ) { val horizontalAlignment = if (isUserMessage) { @@ -109,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) { @@ -126,33 +136,31 @@ fun ChatBubble( Arrangement.Start } - val bubbleShape = if (isUserMessage) { - RoundedCornerShape( + val bubbleShape = when { + isUserMessage && isLastInSequence -> RoundedCornerShape( topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 4.dp ) - } else { - RoundedCornerShape( + !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) - .then( - if (mediaUrl != null) Modifier.width(240.dp) else Modifier - ) + .then(if (mediaUrl != null) Modifier.widthIn(min = 220.dp, max = 280.dp) else Modifier) .background(backgroundColor, bubbleShape), horizontalAlignment = horizontalAlignment, verticalArrangement = Arrangement.spacedBy(4.dp), @@ -161,7 +169,7 @@ fun ChatBubble( Column( modifier = Modifier .then(if (mediaUrl != null) Modifier.fillMaxWidth() else Modifier) - .padding(horizontal = 16.dp, vertical = 10.dp), + .padding(16.dp), horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -194,7 +202,12 @@ fun ChatBubble( if (mediaUrl != null && mediaType == "image") { AsyncImage( - modifier = Modifier.clip(MaterialTheme.shapes.medium).fillMaxWidth(), + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .fillMaxWidth() + .clickable(enabled = onMediaClick != null) { + onMediaClick?.invoke(mediaUrl) + }, model = mediaUrl, contentScale = ContentScale.FillWidth, contentDescription = null @@ -212,8 +225,6 @@ fun ChatBubble( Row( modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium) .clickable { try { if (isPlaying) { @@ -245,16 +256,43 @@ fun ChatBubble( isPlaying = false } } - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - val icon = if (isPreparing) "⏳" else if (isPlaying) "⏸" else "▶" - Text(icon, color = MaterialTheme.colorScheme.primary) + 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) "Preparando..." else if (isPlaying) "Reproduciendo..." else "Nota de voz", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = if (isPreparing) "..." else "0:00", + style = MaterialTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.7f) ) } } @@ -269,6 +307,30 @@ fun ChatBubble( 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) + ) + } + } + } } } } @@ -277,7 +339,7 @@ fun ChatBubble( ChatBubbleType.Location -> { Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment + modifier = modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment ) { Column( modifier = Modifier @@ -303,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) + ) + } } } } @@ -326,7 +393,7 @@ fun ChatBubble( ChatBubbleType.LiveActivity -> { if (place != null) { Row( - modifier = Modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment + modifier = modifier.fillMaxWidth(), horizontalArrangement = bubbleAlignment ) { Column( modifier = Modifier @@ -429,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() diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatListItem.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatListItem.kt index 691c8e1..52393f2 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatListItem.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/ChatListItem.kt @@ -41,7 +41,9 @@ fun ChatListItem( lastMessage: String, status: String? = null, timestamp: String, - hasUnread: Boolean = false + hasUnread: Boolean = false, + unreadCount: Int = 0, + isOnline: Boolean = false ) { Box( modifier = modifier @@ -63,7 +65,7 @@ fun ChatListItem( verticalAlignment = Alignment.CenterVertically ) { Box { - Avatar(user = user) + Avatar(user = user, isOnline = isOnline) } Column(modifier = Modifier.weight(1f)) { @@ -101,20 +103,40 @@ fun ChatListItem( modifier = Modifier.weight(1f) ) - if (timestamp.isNotEmpty()) { - Text( - text = timestamp, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else if (hasUnread) { - Box( - modifier = Modifier - .size(8.dp) - .background( - color = MaterialTheme.colorScheme.onSurface, shape = CircleShape + Column(horizontalAlignment = Alignment.End) { + if (timestamp.isNotEmpty()) { + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = if (unreadCount > 0 || hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (unreadCount > 0) { + Box( + modifier = Modifier + .padding(top = 4.dp) + .size(22.dp) + .background( + color = MaterialTheme.colorScheme.primary, shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = if (unreadCount > 99) "99+" else unreadCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary ) - ) + } + } else if (hasUnread) { + Box( + modifier = Modifier + .padding(top = 4.dp) + .size(8.dp) + .background( + color = MaterialTheme.colorScheme.primary, shape = CircleShape + ) + ) + } } } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt index 004fca5..f77dfe8 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt @@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.FiberManualRecord import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -45,8 +48,10 @@ fun MessageComposer( onValueChange: (String) -> Unit = {}, onSendClick: () -> Unit = {}, onImageClick: () -> Unit = {}, + onCameraClick: () -> Unit = {}, onLocationClick: () -> Unit = {}, - onMicClick: () -> Unit = {} + onMicClick: () -> Unit = {}, + isRecordingAudio: Boolean = false ) { Surface( modifier = modifier.fillMaxWidth(), @@ -57,28 +62,54 @@ fun MessageComposer( Column( modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - // Text input area - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp), - textStyle = MaterialTheme.typography.bodyLarge.copy( - color = MaterialTheme.colorScheme.onSurface - ), - decorationBox = { innerTextField -> - Box { - if (value.isEmpty()) { - Text( - text = "Mensaje", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) + if (isRecordingAudio) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_recording), + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.error + ) + Text( + text = "Grabando audio", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Toca el micrófono para enviar", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + decorationBox = { innerTextField -> + Box { + if (value.isEmpty()) { + Text( + text = "Mensaje", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + innerTextField() } - innerTextField() - } - }) + }) + } // Bottom row: action icons on the left, send button on the right Row( @@ -99,38 +130,58 @@ fun MessageComposer( tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - IconButton(onClick = onLocationClick, modifier = Modifier.size(40.dp)) { + IconButton(onClick = onCameraClick, modifier = Modifier.size(40.dp)) { Icon( - painter = painterResource(id = R.drawable.ic_marker), - contentDescription = "Compartir ubicación", + painter = painterResource(id = R.drawable.ic_camera), + contentDescription = "Tomar foto", modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - IconButton(onClick = onMicClick, modifier = Modifier.size(40.dp)) { + IconButton(onClick = onLocationClick, modifier = Modifier.size(40.dp)) { Icon( - painter = painterResource(id = R.drawable.ic_microphone), - contentDescription = "Grabar audio", + painter = painterResource(id = R.drawable.ic_marker), + contentDescription = "Compartir ubicación", modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) } + IconButton(onClick = onMicClick, modifier = Modifier.size(40.dp)) { + Box(contentAlignment = Alignment.Center) { + if (isRecordingAudio) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.error.copy(alpha = 0.16f)) + ) + } + Icon( + painter = painterResource(id = R.drawable.ic_microphone), + contentDescription = if (isRecordingAudio) "Detener grabación" else "Grabar audio", + modifier = Modifier.size(22.dp), + tint = if (isRecordingAudio) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } // Send button - IconButton( - onClick = onSendClick, - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.secondary) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_send), - contentDescription = "Enviar mensaje", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSecondary - ) + if (!isRecordingAudio) { + IconButton( + onClick = onSendClick, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondary) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_send), + contentDescription = "Enviar mensaje", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSecondary + ) + } } } } @@ -155,4 +206,4 @@ private fun MessageComposerDarkPreview() { MessageComposer() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/friends/FriendRequestItem.kt index 0e5b70c..2e2c295 100644 --- 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 @@ -77,7 +77,7 @@ fun FriendRequestItem( onClick = onDeclineClick, style = RumboButtonStyle.Secondary, size = RumboButtonSize.Small, - icon = painterResource(R.drawable.outline_cancel_24) + 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 index 0c137a7..a54aeab 100644 --- 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 @@ -2,12 +2,16 @@ 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 @@ -81,13 +85,19 @@ fun UserSearchResultItem( icon = painterResource(R.drawable.ic_user) ) } else { - RumboButton( - text = "Agregar", - onClick = onAddClick, - style = RumboButtonStyle.Primary, - size = RumboButtonSize.Small, - icon = painterResource(R.drawable.ic_user_add) - ) + Box( + modifier = Modifier + .size(40.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape) + .clickable(onClick = onAddClick), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_user_add), + contentDescription = "Agregar amigo", + tint = MaterialTheme.colorScheme.onPrimary + ) + } } } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt index ed0c9cb..fb17da8 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt @@ -79,7 +79,7 @@ fun CancelRoute(onClick: () -> Unit = {}) { ) { IconButton(onClick = onClick) { Icon( - painter = painterResource(R.drawable.outline_cancel_24), + painter = painterResource(R.drawable.ic_cancel), contentDescription = "Cancel Route", tint = MaterialTheme.colorScheme.onPrimary, ) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatList.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatList.kt index 7ff2493..92bde82 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatList.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/chat/ChatList.kt @@ -32,6 +32,8 @@ fun ChatList( lastMessage = chat.lastMessage, status = chat.status, timestamp = chat.timestamp, + unreadCount = chat.unreadCount, + hasUnread = chat.hasUnread, modifier = Modifier.clickable { onChatClick(chat) }) } } @@ -42,7 +44,8 @@ data class ChatPreviewData( val lastMessage: String, val status: String? = null, val timestamp: String, - val hasUnread: Boolean = false + val hasUnread: Boolean = false, + val unreadCount: Int = 0 ) private val mockChats = listOf( @@ -89,4 +92,4 @@ private fun AuthPrimaryCTALightPreview() { RumboTheme(darkTheme = false) { ChatList(chatItems = mockChats) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt index cf38d8d..4b027fb 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/Nav.kt @@ -7,14 +7,20 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -28,6 +34,11 @@ import androidx.navigation.compose.rememberNavController import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.navigation.AppScreens import com.appnotresponding.rumbo.ui.theme.RumboTheme +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.database.DataSnapshot +import com.google.firebase.database.DatabaseError +import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.ValueEventListener enum class NavItem { Map, Chat, Plan, Itinerary @@ -43,6 +54,7 @@ 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) { @@ -52,6 +64,50 @@ fun Nav( AppScreens.Itinerary.name -> NavItem.Itinerary else -> NavItem.Map } + + DisposableEffect(Unit) { + val uid = FirebaseAuth.getInstance().currentUser?.uid + if (uid == null) { + onDispose {} + } else { + val db = FirebaseDatabase.getInstance() + val directRef = db.getReference("chats") + val groupRef = db.getReference("groupChats") + var directUnread = 0 + var groupUnread = 0 + + fun readUnread(snapshot: DataSnapshot): Int { + return snapshot.children.sumOf { child -> + child.child("unreadCounts").child(uid).getValue(Int::class.java) + ?: child.child("unreadCounts").child(uid).getValue(Long::class.java)?.toInt() + ?: 0 + } + } + + val directListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + directUnread = readUnread(snapshot) + unreadCount = directUnread + groupUnread + } + + override fun onCancelled(error: DatabaseError) {} + } + val groupListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + groupUnread = readUnread(snapshot) + unreadCount = directUnread + groupUnread + } + + override fun onCancelled(error: DatabaseError) {} + } + directRef.addValueEventListener(directListener) + groupRef.addValueEventListener(groupListener) + onDispose { + directRef.removeEventListener(directListener) + groupRef.removeEventListener(groupListener) + } + } + } Box { Box( modifier = Modifier @@ -118,11 +174,28 @@ fun Nav( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Icon( - painter = painterResource(R.drawable.ic_messages), - contentDescription = "Chat", - tint = if (activeItem == NavItem.Chat) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - ) + Box { + Icon( + painter = painterResource(R.drawable.ic_messages), + contentDescription = "Chat", + tint = if (activeItem == NavItem.Chat) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + if (unreadCount > 0) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .size(18.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), + contentAlignment = Alignment.Center + ) { + Text( + text = if (unreadCount > 9) "9+" else unreadCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + } Text( text = "Chat", color = if (activeItem == NavItem.Chat) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt index 2ac68f4..f72e9f9 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt @@ -48,8 +48,7 @@ fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) { Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp) - .padding(top = 32.dp), + .padding(start = 16.dp, top = 32.dp, end = 16.dp, bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -90,6 +89,7 @@ fun ChatTopBar( activity: String? = null, isGroup: Boolean = false, isMuted: Boolean = false, + isOnline: Boolean = false, onMuteClick: (() -> Unit)? = null, onLeaveClick: (() -> Unit)? = null, onBackClick: (() -> Unit)? = null @@ -102,8 +102,7 @@ fun ChatTopBar( 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 ) { @@ -111,13 +110,13 @@ fun ChatTopBar( if (onBackClick != null) { IconButton(onClick = onBackClick) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, + painter = painterResource(R.drawable.ic_arrow_left), contentDescription = "Atrás", tint = MaterialTheme.colorScheme.onSurface ) } } - Avatar(user = u) + Avatar(user = u, isOnline = isOnline) Column { Text( text = displayName, diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/DropNoteComposer.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/DropNoteComposer.kt index f5d009b..017ed1f 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/DropNoteComposer.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/DropNoteComposer.kt @@ -99,7 +99,7 @@ fun DropNoteComposer( // Botón cámara IconButton(onClick = onImageClick, modifier = Modifier.size(40.dp)) { Icon( - painter = painterResource(id = R.drawable.ic_picture), + painter = painterResource(id = R.drawable.ic_camera), contentDescription = "Cámara", modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt index b2eb611..2dcc08a 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/auth/SignUpScreen.kt @@ -35,12 +35,14 @@ import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController import coil3.compose.AsyncImage +import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.models.RegisterState import com.appnotresponding.rumbo.models.RegisterViewModel import com.appnotresponding.rumbo.navigation.AppScreens @@ -150,7 +152,7 @@ fun SignUpForm( ) } else { Icon( - imageVector = Icons.Rounded.AddAPhoto, + painter = painterResource(R.drawable.ic_add_image), contentDescription = "Seleccionar foto", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(32.dp) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt index 1ca3ba2..602d809 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt @@ -13,6 +13,7 @@ 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 @@ -72,7 +73,8 @@ fun ChatListScreen( floatingActionButton = { FloatingActionButton( onClick = { controller.navigate(AppScreens.Friends.name) }, - containerColor = MaterialTheme.colorScheme.primary + containerColor = MaterialTheme.colorScheme.primary, + shape = CircleShape ) { Icon( painter = painterResource(R.drawable.ic_user_add), @@ -130,7 +132,8 @@ fun ChatListScreen( chatViewModel.selectDirectChat( chatId = convo.chatId, chatTitle = convo.otherUserName, - photoUrl = convo.otherUserPhotoUrl + photoUrl = convo.otherUserPhotoUrl, + isOnline = convo.isOtherUserOnline ) controller.navigate(AppScreens.ChatThread.name) }, @@ -138,7 +141,9 @@ fun ChatListScreen( lastMessage = convo.lastMessage, status = convo.otherUserActivity, timestamp = formatTimestamp(convo.lastMessageTimestamp), - hasUnread = false + hasUnread = convo.unreadCount > 0, + unreadCount = convo.unreadCount, + isOnline = convo.isOtherUserOnline ) } } @@ -170,7 +175,8 @@ fun ChatListScreen( lastMessage = if (isMuted) "🔇 Silenciado" else group.lastMessage, status = "Grupo", timestamp = formatTimestamp(group.lastMessageTimestamp), - hasUnread = false + hasUnread = group.unreadCount > 0, + unreadCount = group.unreadCount ) } } @@ -201,4 +207,4 @@ private fun formatTimestamp(timestamp: Long): String { diff < 86_400_000 -> "${diff / 3_600_000}h" else -> "${diff / 86_400_000}d" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt index 83e0ff1..076b781 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatThreadScreen.kt @@ -1,11 +1,10 @@ package com.appnotresponding.rumbo.ui.screens.chat -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -18,12 +17,23 @@ 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.Alignment @@ -61,10 +71,13 @@ fun ChatThreadScreen( val chatId = chatState.selectedChatId val isGroup = chatState.isGroupChat + var unreadDividerTimestamp by remember(chatId) { mutableStateOf(null) } var mediaRecorder by remember { mutableStateOf(null) } var audioFile by remember { mutableStateOf(null) } var isRecording by remember { mutableStateOf(false) } + var pendingCameraUri by remember { mutableStateOf(null) } + var imagePreviewUrl by remember { mutableStateOf(null) } val context = LocalContext.current @@ -76,12 +89,31 @@ fun ChatThreadScreen( } } - val permissionLauncher = rememberLauncherForActivityResult( + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + val uri = pendingCameraUri + if (success && uri != null) { + chatThreadViewModel.sendMediaMessage(chatId, currentUser.name, uri, isGroup, "image") + } + pendingCameraUri = null + } + + val audioPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { isGranted -> // Handle permission result if needed } + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + pendingCameraUri = createCameraImageUri(context) + pendingCameraUri?.let { cameraLauncher.launch(it) } + } + } + LaunchedEffect(chatId) { if (chatId.isNotBlank()) { if (isGroup) { @@ -94,7 +126,11 @@ fun ChatThreadScreen( LaunchedEffect(threadState.messages.size) { if (threadState.messages.isNotEmpty()) { + if (unreadDividerTimestamp == null) { + unreadDividerTimestamp = threadState.lastReadTimestamp + } listState.animateScrollToItem(threadState.messages.size - 1) + chatThreadViewModel.markChatAsRead(chatId, isGroup) } } @@ -114,6 +150,7 @@ fun ChatThreadScreen( chatAvatarUser = avatarUser, isGroup = isGroup, isMuted = isMuted, + isOnline = !isGroup && (otherUser?.isOnline ?: chatState.selectedChatIsOnline), onMuteClick = { if (isMuted) { chatViewModel.unmuteGroup(chatId) @@ -132,7 +169,7 @@ fun ChatThreadScreen( onMessageInputValueChange = { messageInput = it }, onSendClick = { val text = messageInput.trim() - if (text.isNotBlank()) { + if (!isRecording && text.isNotBlank()) { if (isGroup) { chatThreadViewModel.sendGroupMessage(chatId, currentUser.name, text) } else { @@ -144,6 +181,14 @@ fun ChatThreadScreen( onImageClick = { imagePickerLauncher.launch("image/*") }, + onCameraClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + pendingCameraUri = createCameraImageUri(context) + pendingCameraUri?.let { cameraLauncher.launch(it) } + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, onLocationClick = { val lat = locationState.latitude val lng = locationState.longitude @@ -162,6 +207,7 @@ fun ChatThreadScreen( 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) @@ -183,9 +229,10 @@ fun ChatThreadScreen( } } } else { - permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } - } + }, + isRecordingAudio = isRecording ) { if (threadState.messages.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -198,11 +245,23 @@ fun ChatThreadScreen( } else { LazyColumn( state = listState, - contentPadding = PaddingValues(top = 16.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + contentPadding = PaddingValues(top = 16.dp, bottom = 16.dp) ) { - items(threadState.messages) { msg -> + 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 @@ -221,7 +280,19 @@ fun ChatThreadScreen( } else null 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, @@ -229,12 +300,49 @@ fun ChatThreadScreen( 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 ) } } } + + 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 + ) + } + } + } } } +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 + ) +} + 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 index f2d264b..bf45e36 100644 --- 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 @@ -1,5 +1,11 @@ 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 @@ -19,10 +25,17 @@ 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 @@ -44,8 +57,21 @@ fun FriendsScreen( 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, @@ -57,6 +83,7 @@ fun FriendsScreen( value = searchQuery, onValueChange = { searchQuery = it + contactDiscoveryActive = false if (it.isBlank()) { friendsViewModel.clearSearch() } else { @@ -69,7 +96,27 @@ fun FriendsScreen( Spacer(modifier = Modifier.height(16.dp)) - if (searchQuery.isNotBlank()) { + 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...", @@ -150,3 +197,45 @@ fun FriendsScreen( } } } + +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/templates/ChatThreadTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt index 2331917..0f7c7b7 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ChatThreadTemplate.kt @@ -21,6 +21,7 @@ fun ChatThreadTemplate( chatAvatarUser: User, isGroup: Boolean = false, isMuted: Boolean = false, + isOnline: Boolean = false, onMuteClick: (() -> Unit)? = null, onLeaveClick: (() -> Unit)? = null, onBackClick: (() -> Unit)? = null, @@ -28,8 +29,10 @@ fun ChatThreadTemplate( onMessageInputValueChange: (String) -> Unit = {}, onSendClick: () -> Unit = {}, onImageClick: () -> Unit = {}, + onCameraClick: () -> Unit = {}, onLocationClick: () -> Unit = {}, onMicClick: () -> Unit = {}, + isRecordingAudio: Boolean = false, content: @Composable () -> Unit ) { Scaffold(contentWindowInsets = WindowInsets(0), topBar = { @@ -38,6 +41,7 @@ fun ChatThreadTemplate( activity = chatSubtitle, isGroup = isGroup, isMuted = isMuted, + isOnline = isOnline, onMuteClick = onMuteClick, onLeaveClick = onLeaveClick, onBackClick = onBackClick @@ -53,8 +57,10 @@ fun ChatThreadTemplate( onValueChange = onMessageInputValueChange, onSendClick = onSendClick, onImageClick = onImageClick, + onCameraClick = onCameraClick, onLocationClick = onLocationClick, - onMicClick = onMicClick + onMicClick = onMicClick, + isRecordingAudio = isRecordingAudio ) } }) { paddingValues -> 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 index 920aa19..0231f44 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt @@ -9,6 +9,8 @@ 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 @@ -18,7 +20,8 @@ import kotlinx.coroutines.flow.update data class ChatThreadState( val messages: List = emptyList(), val isSending: Boolean = false, - val messageAuthors: Map = emptyMap() + val messageAuthors: Map = emptyMap(), + val lastReadTimestamp: Long = 0 ) class ChatThreadViewModel : ViewModel() { @@ -32,6 +35,8 @@ class ChatThreadViewModel : ViewModel() { private var currentListener: ValueEventListener? = null private var currentRef: com.google.firebase.database.DatabaseReference? = null + private var currentMetaListener: ValueEventListener? = null + private var currentMetaRef: com.google.firebase.database.DatabaseReference? = null private val dbUsers = db.getReference("users") private val userCache = mutableMapOf() @@ -81,6 +86,22 @@ class ChatThreadViewModel : ViewModel() { currentRef?.let { ref -> currentListener?.let { ref.removeEventListener(it) } } + currentMetaRef?.let { ref -> + currentMetaListener?.let { ref.removeEventListener(it) } + } + + val myUid = auth.currentUser?.uid ?: "" + val metaRef = db.getReference("chats").child(chatId) + currentMetaRef = metaRef + currentMetaListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val lastRead = snapshot.child("lastReadBy").child(myUid).getValue(Long::class.java) ?: 0L + _uiState.update { it.copy(lastReadTimestamp = lastRead) } + } + + override fun onCancelled(error: DatabaseError) {} + } + metaRef.addValueEventListener(currentMetaListener!!) val ref = db.getReference("messages").child(chatId) currentRef = ref @@ -93,7 +114,6 @@ class ChatThreadViewModel : ViewModel() { messages.add(msg) } val parts = chatId.split("_") - val myUid = auth.currentUser?.uid ?: "" val otherUid = parts.firstOrNull { it != myUid } resolveUsersAndEmit(messages.sortedBy { m -> m.timestamp }, otherUid) } @@ -109,6 +129,22 @@ class ChatThreadViewModel : ViewModel() { currentRef?.let { ref -> currentListener?.let { ref.removeEventListener(it) } } + currentMetaRef?.let { ref -> + currentMetaListener?.let { ref.removeEventListener(it) } + } + + val myUid = auth.currentUser?.uid ?: "" + val metaRef = db.getReference("groupChats").child(placeId) + currentMetaRef = metaRef + currentMetaListener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + val lastRead = snapshot.child("lastReadBy").child(myUid).getValue(Long::class.java) ?: 0L + _uiState.update { it.copy(lastReadTimestamp = lastRead) } + } + + override fun onCancelled(error: DatabaseError) {} + } + metaRef.addValueEventListener(currentMetaListener!!) val ref = db.getReference("groupMessages").child(placeId) currentRef = ref @@ -139,6 +175,7 @@ class ChatThreadViewModel : ViewModel() { 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 @@ -151,6 +188,8 @@ class ChatThreadViewModel : ViewModel() { 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) } @@ -174,6 +213,8 @@ class ChatThreadViewModel : ViewModel() { 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) } @@ -205,6 +246,8 @@ class ChatThreadViewModel : ViewModel() { 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) { @@ -213,6 +256,8 @@ class ChatThreadViewModel : ViewModel() { } 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 { @@ -243,6 +288,8 @@ class ChatThreadViewModel : ViewModel() { 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) { @@ -251,6 +298,8 @@ class ChatThreadViewModel : ViewModel() { } 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 { @@ -262,11 +311,57 @@ class ChatThreadViewModel : ViewModel() { } } + fun markChatAsRead(chatId: String, isGroup: Boolean) { + val myUid = auth.currentUser?.uid ?: return + val now = System.currentTimeMillis() + val metaRef = db.getReference(if (isGroup) "groupChats" else "chats").child(chatId) + metaRef.child("lastReadBy").child(myUid).setValue(now) + metaRef.child("unreadCounts").child(myUid).setValue(0) + + val messagesRef = db.getReference(if (isGroup) "groupMessages" else "messages").child(chatId) + messagesRef.get().addOnSuccessListener { snapshot -> + snapshot.children.forEach { child -> + val senderId = child.child("senderId").value as? String ?: return@forEach + if (senderId != myUid) { + child.ref.child("seenBy").child(myUid).setValue(true) + } + } + } + } + + private fun incrementGroupUnreadCounts(placeId: String, myUid: String) { + db.getReference("groupChats").child(placeId).child("participants").get().addOnSuccessListener { snapshot -> + snapshot.children.mapNotNull { it.key }.filter { it != myUid }.forEach { participantUid -> + incrementUnreadCount("groupChats", placeId, participantUid) + } + } + } + + private fun incrementUnreadCount(root: String, chatId: String, recipientUid: String) { + db.getReference(root).child(chatId).child("unreadCounts").child(recipientUid) + .runTransaction(object : Transaction.Handler { + override fun doTransaction(currentData: MutableData): Transaction.Result { + val current = when (val value = currentData.value) { + is Long -> value.toInt() + is Int -> value + else -> 0 + } + currentData.value = current + 1 + return Transaction.success(currentData) + } + + override fun onComplete(error: DatabaseError?, committed: Boolean, currentData: DataSnapshot?) {} + }) + } + override fun onCleared() { super.onCleared() clearUserListeners() currentRef?.let { ref -> currentListener?.let { ref.removeEventListener(it) } } + currentMetaRef?.let { ref -> + currentMetaListener?.let { ref.removeEventListener(it) } + } } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt index 7252b89..0be80d6 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt @@ -21,6 +21,7 @@ data class ChatListState( val selectedChatId: String = "", val selectedChatTitle: String = "", val selectedChatPhoto: String? = null, + val selectedChatIsOnline: Boolean = false, val isGroupChat: Boolean = false ) @@ -115,6 +116,9 @@ class ChatViewModel : ViewModel() { } val lastMessage = child.child("lastMessage").value as? String ?: "" val lastTimestamp = child.child("lastMessageTimestamp").value as? Long ?: 0L + val unreadCount = (child.child("unreadCounts").child(myUid).getValue(Int::class.java) + ?: child.child("unreadCounts").child(myUid).getValue(Long::class.java)?.toInt() + ?: 0).coerceAtLeast(0) db.getReference("friendships").child(myUid).child(otherUid).get().addOnSuccessListener { friendshipSnap -> val areFriends = friendshipSnap.exists() && friendshipSnap.value == true @@ -135,8 +139,10 @@ class ChatViewModel : ViewModel() { otherUserName = user.name, otherUserPhotoUrl = user.profilePictureUrl, otherUserActivity = user.activity, + isOtherUserOnline = user.isOnline, lastMessage = lastMessage, - lastMessageTimestamp = lastTimestamp + lastMessageTimestamp = lastTimestamp, + unreadCount = unreadCount ) ) } else { @@ -199,6 +205,9 @@ class ChatViewModel : ViewModel() { 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 @@ -210,6 +219,7 @@ class ChatViewModel : ViewModel() { placeName = placeName, lastMessage = lastMessage, lastMessageTimestamp = lastTimestamp, + unreadCount = unreadCount, mutedBy = mutedByMap ) @@ -226,12 +236,13 @@ class ChatViewModel : ViewModel() { } } - fun selectDirectChat(chatId: String, chatTitle: String, photoUrl: String?) { + 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 ) } @@ -243,6 +254,7 @@ class ChatViewModel : ViewModel() { selectedChatId = placeId, selectedChatTitle = placeName, selectedChatPhoto = null, + selectedChatIsOnline = false, isGroupChat = true ) } 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 index bb688f2..f1fbdca 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt @@ -181,6 +181,46 @@ class FriendsViewModel : ViewModel() { } } + fun searchUsersByContacts(emails: Set, phones: Set) { + val myUid = auth.currentUser?.uid ?: return + if (emails.isEmpty() && phones.isEmpty()) { + _uiState.update { + it.copy( + searchResults = emptyList(), + searchError = "No encontramos emails o teléfonos en tus contactos" + ) + } + return + } + + _uiState.update { it.copy(isSearching = true, searchError = null) } + val normalizedEmails = emails.map { it.lowercase().trim() }.toSet() + val normalizedPhones = phones.map { normalizePhone(it) }.filter { it.isNotBlank() }.toSet() + + dbUsers.get().addOnSuccessListener { snapshot -> + val results = mutableListOf() + for (child in snapshot.children) { + val user = child.getValue(User::class.java) ?: continue + val userEmail = user.email.lowercase().trim() + val userPhone = normalizePhone(user.phone) + val matchesEmail = userEmail.isNotBlank() && normalizedEmails.contains(userEmail) + val matchesPhone = userPhone.isNotBlank() && normalizedPhones.contains(userPhone) + if (user.id != myUid && (matchesEmail || matchesPhone)) { + results.add(user) + } + } + _uiState.update { + it.copy( + searchResults = results.distinctBy { user -> user.id }, + isSearching = false, + searchError = if (results.isEmpty()) "No encontramos amigos de Rumbo en tus contactos" else null + ) + } + }.addOnFailureListener { + _uiState.update { state -> state.copy(isSearching = false, searchError = "Error al revisar contactos") } + } + } + fun addFriend(targetUid: String) { val myUid = auth.currentUser?.uid ?: return if (targetUid == myUid) return @@ -219,6 +259,10 @@ class FriendsViewModel : ViewModel() { _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) { diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt index b89db5c..6e164a9 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt @@ -6,6 +6,7 @@ import com.google.firebase.auth.FirebaseAuth import com.google.firebase.database.DataSnapshot import com.google.firebase.database.DatabaseError import com.google.firebase.database.FirebaseDatabase +import com.google.firebase.database.ServerValue import com.google.firebase.database.ValueEventListener import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,7 +14,10 @@ import kotlinx.coroutines.flow.asStateFlow class UserViewModel : ViewModel() { private val auth = FirebaseAuth.getInstance() - private val dbRef = FirebaseDatabase.getInstance().getReference("users") + private val database = FirebaseDatabase.getInstance() + private val dbRef = database.getReference("users") + private val connectedRef = database.getReference(".info/connected") + private var presenceListener: ValueEventListener? = null private val _currentUserState = MutableStateFlow(null) val currentUserState: StateFlow = _currentUserState.asStateFlow() @@ -24,12 +28,30 @@ class UserViewModel : ViewModel() { val uid = firebaseAuth.currentUser?.uid if (uid != null) { fetchUserData(uid) + setupPresence(uid) } else { _currentUserState.value = null } } } + private fun setupPresence(uid: String) { + presenceListener?.let { connectedRef.removeEventListener(it) } + val userStatusRef = dbRef.child(uid) + val listener = object : ValueEventListener { + override fun onDataChange(snapshot: DataSnapshot) { + if (snapshot.getValue(Boolean::class.java) != true) return + userStatusRef.child("isOnline").onDisconnect().setValue(false) + userStatusRef.child("lastSeenAt").onDisconnect().setValue(ServerValue.TIMESTAMP) + userStatusRef.child("isOnline").setValue(true) + } + + override fun onCancelled(error: DatabaseError) {} + } + presenceListener = listener + connectedRef.addValueEventListener(listener) + } + private fun fetchUserData(uid: String) { android.util.Log.d("UserViewModel", "Starting ValueEventListener for uid: $uid") dbRef.child(uid).addValueEventListener(object : ValueEventListener { @@ -80,5 +102,14 @@ class UserViewModel : ViewModel() { android.util.Log.e("UserViewModel", "Failed to set activity to $activity: ${e.message}", e) } } + + override fun onCleared() { + super.onCleared() + presenceListener?.let { connectedRef.removeEventListener(it) } + auth.currentUser?.uid?.let { uid -> + dbRef.child(uid).child("isOnline").setValue(false) + dbRef.child(uid).child("lastSeenAt").setValue(ServerValue.TIMESTAMP) + } + } } diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000..c746152 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_cancel.xml b/app/src/main/res/drawable/ic_cancel.xml new file mode 100644 index 0000000..2539a00 --- /dev/null +++ b/app/src/main/res/drawable/ic_cancel.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_minus.xml b/app/src/main/res/drawable/ic_minus.xml index 88184ed..41a21cf 100644 --- a/app/src/main/res/drawable/ic_minus.xml +++ b/app/src/main/res/drawable/ic_minus.xml @@ -1,10 +1,9 @@ - - \ No newline at end of file + + diff --git a/app/src/main/res/drawable/ic_recording.xml b/app/src/main/res/drawable/ic_recording.xml new file mode 100644 index 0000000..5633b88 --- /dev/null +++ b/app/src/main/res/drawable/ic_recording.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/outline_cancel_24.xml b/app/src/main/res/drawable/outline_cancel_24.xml deleted file mode 100644 index 0c7e845..0000000 --- a/app/src/main/res/drawable/outline_cancel_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - From a9e2abed3735250c2002600211cd68f994961c7c Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 2 Jun 2026 18:26:09 -0500 Subject: [PATCH 31/49] =?UTF-8?q?feat:=20completa=20funcionalidades=20en?= =?UTF-8?q?=20vivo=20de=20POI,=20rese=C3=B1as=20y=20mapa=20de=20calor=20(I?= =?UTF-8?q?ssues=20#50,=20#51=20y=20#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + .../rumbo/models/placeState.kt | 3 +- .../appnotresponding/rumbo/models/review.kt | 11 +- .../rumbo/ui/components/atoms/Rating.kt | 4 +- .../molecules/map/MapFloatingActions.kt | 20 +++ .../components/molecules/map/PlaceReview.kt | 60 ++++++- .../components/molecules/plan/PlanItemCard.kt | 12 +- .../components/organisms/map/POICardRview.kt | 11 +- .../organisms/map/ReviewComposer.kt | 160 +++++++++++++++++ .../components/organisms/plan/PlanPOIList.kt | 28 +-- .../rumbo/ui/templates/MapTemplate.kt | 167 ++++++++++++++++-- .../rumbo/ui/templates/PlanTemplate.kt | 2 +- .../rumbo/ui/viewModel/mapViewModel.kt | 62 ++++++- .../rumbo/ui/viewModel/placesViewModel.kt | 97 +++++++++- gradle/libs.versions.toml | 1 + 15 files changed, 585 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/map/ReviewComposer.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7fab7ae..742cafb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -90,6 +90,7 @@ dependencies { 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") diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt index d94f998..5fc091c 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt @@ -3,5 +3,6 @@ package com.appnotresponding.rumbo.models data class PlaceState( val availablePlaces: List = emptyList(), val itinerary: List = emptyList(), - val selectedPlace: Place? = null + val selectedPlace: Place? = null, + val previewedPlace: Place? = null ) \ No newline at end of file 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/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/molecules/map/MapFloatingActions.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/map/MapFloatingActions.kt index ed0c9cb..0ef4c2e 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 @@ -87,6 +87,26 @@ fun CancelRoute(onClick: () -> Unit = {}) { } } +@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( + painter = painterResource(R.drawable.ic_map), // Reusing map icon or any suitable icon + contentDescription = "Toggle Heatmap", + tint = iconTint, + ) + } + } +} + @Preview(showBackground = true, name = "MapFloatingActions - Light") @Composable private fun MapFloatingActionsLightPreview() { 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 76bbdbe..65e2bdd 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 @@ -38,7 +38,7 @@ 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 = {}) { Row( modifier = Modifier .fillMaxWidth() @@ -78,17 +78,49 @@ 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 ?: "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) + ) + RumboButton( + modifier = Modifier.fillMaxWidth(), + text = "Escribir Reseña", + onClick = onReviewClick, + style = RumboButtonStyle.Secondary, + icon = painterResource(R.drawable.ic_text_box_edit) + ) + } } } } @@ -122,10 +154,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 4c6b452..63a9049 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 @@ -22,6 +22,9 @@ 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 androidx.compose.foundation.clickable +import androidx.navigation.NavHostController +import com.appnotresponding.rumbo.navigation.AppScreens import coil3.compose.SubcomposeAsyncImage import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.models.Place @@ -38,7 +41,7 @@ import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel * @param p El lugar a mostrar en la tarjeta. */ @Composable -fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel) { +fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel, controller: NavHostController) { val uiState by placesViewModel.uiState.collectAsState() val isInItinerary = uiState.itinerary.any { it.id == p.id } @@ -52,7 +55,12 @@ fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel) { .aspectRatio(1f) .padding(start = 8.dp, end = 8.dp, bottom = 8.dp, top = 2.dp) .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.secondaryContainer), + .background(MaterialTheme.colorScheme.secondaryContainer) + .clickable { + placesViewModel.showPreview(p) + placesViewModel.selectForNavigation(p) + controller.navigate(AppScreens.Map.name) + }, contentAlignment = Alignment.Center ) { SubcomposeAsyncImage( 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..fed4075 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 @@ -29,17 +30,23 @@ 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 PlacePreviewCard( + place: Place, + reviews: List, + onNavigateClick: () -> Unit = {}, + onReviewClick: () -> Unit = {} +) { Surface(shape = MaterialTheme.shapes.large) { Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp) ) { - PlaceInfo(p = place) + PlaceInfo(p = place, onNavigateClick = onNavigateClick, onReviewClick = onReviewClick) LazyColumn( modifier = Modifier .fillMaxWidth() + .heightIn(max = 350.dp) .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(vertical = 8.dp) 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/plan/PlanPOIList.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/plan/PlanPOIList.kt index e3f142f..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 @@ -26,15 +26,17 @@ import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel * @param places La lista de lugares (Place) que se van a mostrar en la pantalla. */ +import androidx.navigation.NavHostController + @Composable -fun PlanPOIList(places: List, placesViewModel: PlacesViewModel) { +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, placesViewModel) + PlanItemCard(p = place, placesViewModel, controller) } item { Spacer(modifier = Modifier.height(96.dp)) @@ -46,20 +48,20 @@ fun PlanPOIList(places: List, placesViewModel: PlacesViewModel) { @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) - ) - } +RumboTheme(darkTheme = true) { +PlanPOIList( +places = listOf(samplePlace, samplePlace, samplePlace) +) +} } - */ \ 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 a6d1fa7..666d09c 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 @@ -3,6 +3,7 @@ package com.appnotresponding.rumbo.ui.templates 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 @@ -17,6 +18,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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 @@ -61,6 +64,7 @@ import com.appnotresponding.rumbo.ui.components.atoms.AvatarSize import com.appnotresponding.rumbo.ui.components.atoms.UserProfileBubble import com.appnotresponding.rumbo.ui.components.molecules.map.CancelRoute 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 @@ -95,6 +99,8 @@ 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 org.osmdroid.util.GeoPoint @@ -122,12 +128,18 @@ fun MapTemplate( val placesState by placesViewModel.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) } + val markerKey = remember(user.profilePictureUrl) { user.profilePictureUrl ?: "" } var profileBitmap by remember(user.profilePictureUrl) { mutableStateOf(null) } LaunchedEffect(user.profilePictureUrl) { @@ -173,7 +185,7 @@ fun MapTemplate( userLocationState.longitude, state.centerInUserFirstTime ) { - Log.d("RECOMPOSE", "Enntrando en launch") + Log.d("RECOMPOSE", "Enntrando en launch de location") val tieneUbicacionReal = userLocationState.latitude != 0.0 || userLocationState.longitude != 0.0 if (tieneUbicacionReal) { @@ -195,8 +207,11 @@ fun MapTemplate( ) viewModel.updateCenterInUserFirstTime() } - // Si aún no hay ubicación real, la cámara se queda en Bogotá (valor inicial) } + } + + 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) @@ -204,23 +219,29 @@ fun MapTemplate( placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude ) val points = arrayListOf(startPoint, destination) - val road = roadManager.getRoad(points) - val routePoints = road.mRouteHigh.map { geoPoint -> - LatLng(geoPoint.latitude, geoPoint.longitude) + 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) } - viewModel.updateRoutePoints(routePoints) } - if (placesState.selectedPlace != null) { viewModel.updateAdditionalMarker( LatLng( placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude ), placesState.selectedPlace!!.name ) - GeoPoint( - placesState.selectedPlace!!.latitude, placesState.selectedPlace!!.longitude - ) + } else { + viewModel.updateRoutePoints(emptyList()) } } @@ -246,6 +267,10 @@ fun MapTemplate( viewModel.cancelAdditionalMarkerVisibility() } } + ToggleHeatmap( + isHeatmapActive = state.isHeatmapVisible, + onClick = { viewModel.toggleHeatmap() } + ) WriteDropNote { popupStateDNComposer = !popupStateDNComposer } @@ -320,6 +345,41 @@ fun MapTemplate( ) } + if (state.isHeatmapVisible && state.heatmapClusters.isNotEmpty()) { + state.heatmapClusters.forEach { cluster -> + val color = when { + cluster.count == 1 -> androidx.compose.ui.graphics.Color.Cyan + cluster.count == 2 -> androidx.compose.ui.graphics.Color.Green + cluster.count == 3 -> androidx.compose.ui.graphics.Color(0xFFFFA500) // Naranja + else -> androidx.compose.ui.graphics.Color.Red + } + val radius = when { + cluster.count == 1 -> 30.0 + cluster.count == 2 -> 60.0 + cluster.count == 3 -> 90.0 + else -> 120.0 + } + com.google.maps.android.compose.Circle( + center = cluster.position, + fillColor = color.copy(alpha = 0.5f), + strokeColor = color, + radius = radius + ) + } + } + + placesState.availablePlaces.forEach { place -> + val position = LatLng(place.latitude, place.longitude) + Marker( + state = rememberUpdatedMarkerState(position), + title = place.name, + onClick = { + placesViewModel.showPreview(place) + true + } + ) + } + dropNoteState.dropNotes.forEach { note -> val position = LatLng(note.latitude, note.longitude) val author = dropNoteState.dropNoteAuthors[note.creatorId] @@ -444,15 +504,96 @@ fun MapTemplate( } } } - 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 = {} + )) { + PlacePreviewCard( + place = currentPreviewedPlace, + reviews = currentPreviewedPlace.reviews, + onNavigateClick = { + placesViewModel.selectForNavigation(currentPreviewedPlace) + placesViewModel.showPreview(null) + }, + onReviewClick = { + popupStateReviewComposer = true + } + ) + } + } + } + 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") + } + ) + } + ) + } } } if (popupStateViewDN && selectedDropNote != null) { 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 24fc2ad..0d6830f 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 @@ -51,7 +51,7 @@ fun PlanTemplate( Spacer(modifier = Modifier.height(16.dp)) - PlanPOIList(places = placesList, placesViewModel) + PlanPOIList(places = placesList, placesViewModel, controller) } } } 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 index 146fc8c..2287267 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt @@ -9,6 +9,8 @@ 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)), @@ -18,7 +20,9 @@ data class MapState( val userRouteVisible: Boolean = false, val place: String = "", val centerInUserFirstTime: Boolean = true, - val lastSafeLatLng: LatLng = LatLng(0.0, 0.0) + val lastSafeLatLng: LatLng = LatLng(0.0, 0.0), + val isHeatmapVisible: Boolean = false, + val heatmapClusters: List = emptyList() ) class MapViewModel : ViewModel() { @@ -26,6 +30,58 @@ 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) } + } + + 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) } } @@ -39,8 +95,8 @@ class MapViewModel : ViewModel() { } fun updateAdditionalMarker(position: LatLng, title: String) { _uiState.update { it.copy(additionalMarker = MyMarker(position), additionalMarkerVisible = true - ) - } + ) + } } fun cancelAdditionalMarkerVisibility() { diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt index c7742b9..e4c8867 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt @@ -12,8 +12,58 @@ class PlacesViewModel : ViewModel() { private val _uiState = MutableStateFlow(PlaceState()) val uiState: StateFlow = _uiState.asStateFlow() + private var rawPlaces: List = emptyList() + private var firebaseReviews: Map> = emptyMap() + + init { + observeReviews() + } + + 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) { - _uiState.update { it.copy(availablePlaces = list) } + rawPlaces = list + mergePlacesWithReviews() + } + + private fun mergePlacesWithReviews() { + _uiState.update { currentState -> + val merged = rawPlaces.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 addToItinerary(place: Place) { @@ -23,18 +73,57 @@ class PlacesViewModel : ViewModel() { } } - fun removeFromItinerary(place: Place) { + fun removeFromItinerary(place: Place) { val current = _uiState.value.itinerary if (current.any { it.id == place.id }) { _uiState.update { it.copy(itinerary = current.filterNot { it.id == place.id }) } } } - fun selectForNavigation(place: Place) { + fun selectForNavigation(place: Place?) { _uiState.update { it.copy(selectedPlace = place) } } + fun showPreview(place: Place?) { + _uiState.update { it.copy(previewedPlace = place) } + } + fun clearForNavigation() { _uiState.update { it.copy(selectedPlace = null) } } -} \ No newline at end of file + + 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8b68376..96a21a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,6 +61,7 @@ androidx-credentials = { group = "androidx.credentials", name = "credentials", v 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" } From 79d6b1d70b9134ca73161f98cc4aeada9fcef4e5 Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Tue, 2 Jun 2026 18:29:17 -0500 Subject: [PATCH 32/49] [Feat]: Implement Profile screen and refine chat UI This commit introduces a dedicated profile screen and enhances various UI elements: * **Add Profile Screen**: Integrates the `ProfileScreen` into the main navigation flow. * **Centralize Profile Navigation**: Refactors `MainTopBar` to directly navigate to the `ProfileScreen` upon avatar click, eliminating redundant `onProfileClick` callbacks in individual screens and templates. * **Improve Chat List Item**: Adjusts the layout of `ChatListItem` for better visual balance between message content and timestamp/unread indicators. * **Refine Navigation Badge**: Repositions the unread message badge in the bottom navigation for improved alignment. * **UI Cleanup**: Removes unused imports, commented-out preview functions, and the mute option from `ChatTopBar`. --- .../rumbo/navigation/navigation.kt | 6 +- .../components/molecules/chat/ChatListItem.kt | 71 +++++++++---------- .../molecules/chat/MessageComposer.kt | 3 - .../ui/components/organisms/common/Nav.kt | 4 +- .../ui/components/organisms/common/TopBar.kt | 29 +++----- .../rumbo/ui/screens/chat/ChatListScreen.kt | 9 +-- .../ui/screens/itinerary/ItineraryScreen.kt | 5 +- .../rumbo/ui/screens/map/MapScreen.kt | 14 ++-- .../rumbo/ui/screens/plan/PlanScreen.kt | 5 +- .../rumbo/ui/templates/ChatTemplate.kt | 3 +- .../rumbo/ui/templates/FriendsTemplate.kt | 5 +- .../rumbo/ui/templates/ItineraryTemplate.kt | 8 +-- .../rumbo/ui/templates/MapTemplate.kt | 3 +- .../rumbo/ui/templates/PlanTemplate.kt | 3 +- 14 files changed, 69 insertions(+), 99 deletions(-) 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 c03a929..5a8fd6b 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt @@ -38,7 +38,8 @@ enum class AppScreens { Plan, Itinerary, OnBoarding, - Friends + Friends, + Profile } @Composable @@ -78,5 +79,8 @@ fun Navigation( composable(route = AppScreens.Friends.name) { FriendsScreen(navController, userViewModel, friendsViewModel, chatViewModel) } + composable(route = AppScreens.Profile.name) { + ProfileScreen(navController, userViewModel) + } } } 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 52393f2..9cca824 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 @@ -68,7 +68,7 @@ fun ChatListItem( Avatar(user = user, isOnline = isOnline) } - Column(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.weight(8.5f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = user.name, @@ -88,7 +88,6 @@ fun ChatListItem( ) } } - Box(modifier = Modifier.padding(top = 4.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -103,43 +102,43 @@ fun ChatListItem( modifier = Modifier.weight(1f) ) - Column(horizontalAlignment = Alignment.End) { - if (timestamp.isNotEmpty()) { - Text( - text = timestamp, - style = MaterialTheme.typography.labelSmall, - color = if (unreadCount > 0 || hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (unreadCount > 0) { - Box( - modifier = Modifier - .padding(top = 4.dp) - .size(22.dp) - .background( - color = MaterialTheme.colorScheme.primary, shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Text( - text = if (unreadCount > 99) "99+" else unreadCount.toString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } else if (hasUnread) { - Box( - modifier = Modifier - .padding(top = 4.dp) - .size(8.dp) - .background( - color = MaterialTheme.colorScheme.primary, shape = CircleShape - ) - ) - } + } + } + Column(modifier = Modifier.weight(1.5f), horizontalAlignment = Alignment.End) { + if (timestamp.isNotEmpty()) { + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = if (unreadCount > 0 || hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (unreadCount > 0) { + Box( + modifier = Modifier + .padding(top = 4.dp) + .size(22.dp) + .background( + color = MaterialTheme.colorScheme.primary, shape = CircleShape + ), contentAlignment = Alignment.Center + ) { + Text( + text = if (unreadCount > 99) "99+" else unreadCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary + ) } + } else if (hasUnread) { + Box( + modifier = Modifier + .padding(top = 4.dp) + .size(8.dp) + .background( + color = MaterialTheme.colorScheme.primary, shape = CircleShape + ) + ) } } + } } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/molecules/chat/MessageComposer.kt index f77dfe8..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 @@ -9,9 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PhotoCamera -import androidx.compose.material.icons.filled.FiberManualRecord import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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 4b027fb..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,6 +6,7 @@ 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 @@ -183,7 +184,8 @@ fun Nav( if (unreadCount > 0) { Box( modifier = Modifier - .align(Alignment.TopStart) + .align(Alignment.TopEnd) + .offset(x = (8).dp, y = (-4).dp) .size(18.dp) .background(MaterialTheme.colorScheme.primary, CircleShape), contentAlignment = Alignment.Center 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 f72e9f9..9bce8af 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 @@ -10,10 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ExitToApp -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -25,9 +22,11 @@ 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.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 @@ -39,7 +38,7 @@ import com.appnotresponding.rumbo.ui.theme.RumboTheme * y la foto de perfil para el avatar. */ @Composable -fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) { +fun MainTopBar(u: User, controller: NavHostController) { val bottomRoundedShape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) val displayName = u.name.replace(Regex(" +$"), "") Surface( @@ -67,7 +66,9 @@ fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) { style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface ) - Avatar(modifier = Modifier.clickable(onClick = onProfileClick), user = u) + Avatar(modifier = Modifier.clickable(onClick = { + controller.navigate(AppScreens.Profile.name) + }), user = u) } } } @@ -85,7 +86,7 @@ fun MainTopBar(u: User, onProfileClick: () -> Unit = {}) { */ @Composable fun ChatTopBar( - u: User, + u: User, activity: String? = null, isGroup: Boolean = false, isMuted: Boolean = false, @@ -136,15 +137,6 @@ fun ChatTopBar( } if (isGroup) { Row { - if (onMuteClick != null) { - IconButton(onClick = onMuteClick) { - Icon( - imageVector = if (isMuted) Icons.Filled.NotificationsOff else Icons.Filled.Notifications, - contentDescription = if (isMuted) "Desilenciar" else "Silenciar", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } if (onLeaveClick != null) { IconButton(onClick = onLeaveClick) { Icon( @@ -160,11 +152,12 @@ fun ChatTopBar( } } +/* @Preview(showBackground = true, name = "MainTopBar - Light") @Composable private fun MainTopBarLightPreview() { RumboTheme(darkTheme = false) { - MainTopBar(u = sampleUser) + MainTopBar(u = sampleUser,) } } @@ -172,7 +165,7 @@ private fun MainTopBarLightPreview() { @Composable private fun MainTopBarDarkPreview() { RumboTheme(darkTheme = true) { - MainTopBar(u = sampleUser) + MainTopBar(u = sampleUser,) } } @@ -190,4 +183,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/screens/chat/ChatListScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt index 602d809..a68e3b2 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 @@ -28,7 +28,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import com.appnotresponding.rumbo.R -import com.appnotresponding.rumbo.auth import com.appnotresponding.rumbo.models.User import com.appnotresponding.rumbo.models.sampleUser import com.appnotresponding.rumbo.navigation.AppScreens @@ -61,13 +60,7 @@ fun ChatListScreen( Scaffold( contentWindowInsets = WindowInsets(0), topBar = { - MainTopBar(u = currentUser, onProfileClick = { - auth.signOut() - controller.navigate(AppScreens.Splash.name) { - popUpTo(controller.graph.startDestinationId) { inclusive = true } - launchSingleTop = true - } - }) + MainTopBar(u = currentUser, controller = controller) }, bottomBar = { Nav(controller) }, floatingActionButton = { 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 51c9e3d..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 @@ -5,7 +5,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation.NavHostController import com.appnotresponding.rumbo.models.sampleUser -import com.appnotresponding.rumbo.navigation.AppScreens import com.appnotresponding.rumbo.ui.templates.ItineraryTemplate import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel import com.appnotresponding.rumbo.ui.viewModel.UserViewModel @@ -19,8 +18,6 @@ fun ItineraryScreen( val user = userState ?: sampleUser.copy(name = "Cargando...") ItineraryTemplate( - user = user, itineraryList = state.itinerary, controller = controller, onProfileClick = { - controller.navigate(AppScreens.Profile.name) - }, placesViewModel + user = user, itineraryList = state.itinerary, controller = controller, placesViewModel = placesViewModel ) } 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 cfb33e7..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 @@ -5,14 +5,12 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.viewModel.FriendsViewModel import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel import com.appnotresponding.rumbo.ui.viewModel.UserLocationViewModel import com.appnotresponding.rumbo.ui.viewModel.UserViewModel -import com.appnotresponding.rumbo.ui.viewModel.FriendsViewModel - @Composable fun MapScreen( controller: NavHostController, @@ -25,9 +23,11 @@ fun MapScreen( val user = userState ?: sampleUser.copy(name = "Cargando...") MapTemplate( - user = user, controller = controller, onProfileClick = { - controller.navigate(AppScreens.Profile.name) - }, placesViewModel = placesViewModel, locationViewModel = locationViewModel, - userViewModel = userViewModel, friendsViewModel = friendsViewModel + user = user, + controller = controller, + placesViewModel = placesViewModel, + locationViewModel = locationViewModel, + userViewModel = userViewModel, + friendsViewModel = friendsViewModel ) } 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 a08a23d..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 @@ -62,9 +62,6 @@ fun PlanScreen( user = user, placesList = placesState.availablePlaces, controller = controller, - onProfileClick = { - controller.navigate(AppScreens.Profile.name) - }, - placesViewModel + placesViewModel = placesViewModel ) } 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 402d4c2..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 @@ -24,12 +24,11 @@ fun ChatTemplate( subtitle: String, modifier: Modifier = Modifier, controller: NavHostController, - onProfileClick: () -> Unit = {}, content: @Composable () -> Unit ) { Scaffold( contentWindowInsets = WindowInsets(0), - topBar = { MainTopBar(u = currentUser, onProfileClick = onProfileClick) }, + topBar = { MainTopBar(u = currentUser, controller = controller) }, bottomBar = { Nav(controller) }) { paddingValues -> Column( modifier = modifier 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 index 232d926..0d67b44 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/FriendsTemplate.kt @@ -1,11 +1,9 @@ package com.appnotresponding.rumbo.ui.templates -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -21,13 +19,12 @@ import com.appnotresponding.rumbo.ui.components.organisms.common.Nav @Composable fun FriendsTemplate( currentUser: User, - onProfileClick: () -> Unit = {}, controller: NavHostController, content: @Composable () -> Unit ) { Scaffold( contentWindowInsets = WindowInsets(0), - topBar = { MainTopBar(u = currentUser, onProfileClick = onProfileClick) }, + topBar = { MainTopBar(u = currentUser, controller = controller) }, bottomBar = { Nav(controller) } ) { paddingValues -> Column( 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 c381336..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 @@ -11,19 +11,14 @@ 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 /** @@ -42,12 +37,11 @@ fun ItineraryTemplate( user: User, itineraryList: List, controller: NavHostController, - onProfileClick: () -> Unit = {}, placesViewModel: PlacesViewModel ) { Scaffold( contentWindowInsets = WindowInsets(0), - topBar = { MainTopBar(u = user, onProfileClick = onProfileClick) }, + topBar = { MainTopBar(u = user, controller = controller) }, bottomBar = { Nav(controller) }) { paddingValues -> Column( modifier = Modifier 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 0654bd6..8188439 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 @@ -112,7 +112,6 @@ var locationRequest: LocationRequest = createLocationRequest() fun MapTemplate( user: User, controller: NavHostController, - onProfileClick: () -> Unit = {}, viewModel: MapViewModel = viewModel(), dropNoteViewModel: DropNoteViewModel = viewModel(), itineraryHistoryViewModel: ItineraryHistoryViewModel = viewModel(), @@ -265,7 +264,7 @@ fun MapTemplate( } Scaffold( contentWindowInsets = WindowInsets(0), - topBar = { MainTopBar(user, onProfileClick = onProfileClick) }, + topBar = { MainTopBar(user, controller = controller) }, floatingActionButton = { Column( modifier = Modifier 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 24fc2ad..358c650 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 @@ -35,12 +35,11 @@ fun PlanTemplate( user: User, placesList: List, controller: NavHostController, - onProfileClick: () -> Unit = {}, placesViewModel: PlacesViewModel ) { Scaffold( contentWindowInsets = WindowInsets(0), - topBar = { MainTopBar(u = user, onProfileClick = onProfileClick) }, + topBar = { MainTopBar(u = user, controller = controller) }, bottomBar = { Nav(controller) }) { paddingValues -> Column( modifier = Modifier From 4baf9b00d793585644b29ba6a1da96445123ec70 Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Tue, 2 Jun 2026 19:06:27 -0500 Subject: [PATCH 33/49] [Feat]: UI Fixes --- app/src/main/AndroidManifest.xml | 3 +- .../rumbo/models/chatConversation.kt | 1 + .../rumbo/ui/components/atoms/Avatar.kt | 13 ++++--- .../components/molecules/chat/ChatListItem.kt | 17 ++++++--- .../molecules/friends/FriendRequestItem.kt | 6 ++-- .../molecules/map/MapFloatingActions.kt | 3 ++ .../ui/components/organisms/common/TopBar.kt | 35 +++++++++---------- .../rumbo/ui/screens/chat/ChatListScreen.kt | 12 ++++--- .../rumbo/ui/templates/ChatThreadTemplate.kt | 2 ++ .../rumbo/ui/templates/MapTemplate.kt | 3 +- .../rumbo/ui/viewModel/chatViewModel.kt | 5 +-- 11 files changed, 60 insertions(+), 40 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0ace41c..3711e0f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,7 +26,8 @@ + android:theme="@style/Theme.Rumbo" + android:windowSoftInputMode="adjustResize"> diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt b/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt index 9b18473..abbbcf8 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/chatConversation.kt @@ -15,6 +15,7 @@ data class ChatConversation( data class GroupChat( val placeId: String = "", val placeName: String = "", + val placePhotoUrl: String? = null, val lastMessage: String = "", val lastMessageTimestamp: Long = 0, val unreadCount: Int = 0, 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 e2c6d51..b411ee5 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,6 +10,7 @@ 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 @@ -79,6 +80,8 @@ fun Avatar( } // 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 @@ -106,8 +109,8 @@ fun Avatar( .fillMaxSize() .background(color = backgroundColor, shape = CircleShape) .then( - if (borderWidth > 0.dp) { - Modifier.border(borderWidth, borderColor, CircleShape) + if (onlineBorderWidth > 0.dp) { + Modifier.border(onlineBorderWidth, onlineBorderColor, CircleShape) } else { Modifier } @@ -177,17 +180,19 @@ fun Avatar( } } + } + // Indicador de online if (isOnline) { Box( modifier = Modifier .align(Alignment.BottomEnd) .size(indicatorSize) + .sizeIn(minWidth = indicatorSize, minHeight = indicatorSize) .background(Color(0xFF4CAF50), CircleShape) .border(indicatorBorderWidth, MaterialTheme.colorScheme.surface, CircleShape) ) } - } } } @@ -247,4 +252,4 @@ private fun AvatarDarkPreview() { } } -} \ No newline at end of file +} 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 9cca824..6082883 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 @@ -68,12 +68,18 @@ fun ChatListItem( Avatar(user = user, isOnline = isOnline) } - Column(modifier = Modifier.weight(8.5f)) { - Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + 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) { Text( @@ -85,6 +91,9 @@ fun ChatListItem( text = status, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) ) } } @@ -104,7 +113,7 @@ fun ChatListItem( } } - Column(modifier = Modifier.weight(1.5f), horizontalAlignment = Alignment.End) { + Column(horizontalAlignment = Alignment.End) { if (timestamp.isNotEmpty()) { Text( text = timestamp, 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 index 2e2c295..83d0527 100644 --- 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 @@ -61,9 +61,9 @@ fun FriendRequestItem( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { RumboButton( text = "Aceptar", 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 fb17da8..a14d040 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 @@ -37,6 +37,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 +62,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, @@ -79,6 +81,7 @@ fun CancelRoute(onClick: () -> Unit = {}) { ) { IconButton(onClick = onClick) { Icon( + modifier = Modifier.size(24.dp), painter = painterResource(R.drawable.ic_cancel), contentDescription = "Cancel Route", tint = MaterialTheme.colorScheme.onPrimary, diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/components/organisms/common/TopBar.kt index 9bce8af..5dd1e36 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 @@ -20,6 +20,7 @@ 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 @@ -107,7 +108,10 @@ fun ChatTopBar( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { if (onBackClick != null) { IconButton(onClick = onBackClick) { Icon( @@ -118,36 +122,29 @@ fun ChatTopBar( } } Avatar(user = u, isOnline = isOnline) - Column { + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + ) { Text( text = displayName, style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(start = 8.dp), - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) if (!activity.isNullOrBlank()) { Text( text = activity, style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(start = 8.dp), - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } } - if (isGroup) { - Row { - if (onLeaveClick != null) { - IconButton(onClick = onLeaveClick) { - Icon( - imageVector = Icons.Filled.ExitToApp, - contentDescription = "Salir", - tint = MaterialTheme.colorScheme.error - ) - } - } - } - } } } } diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/screens/chat/ChatListScreen.kt index a68e3b2..c589fc1 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 @@ -152,21 +152,23 @@ fun ChatListScreen( ) } items(chatState.groupChats) { group -> - val isMuted = group.mutedBy[myUid] == true - val groupUser = User(name = group.placeName) + val groupUser = User( + name = group.placeName, + profilePictureUrl = group.placePhotoUrl + ) ChatListItem( modifier = Modifier .fillMaxWidth() .clickable { chatViewModel.selectGroupChat( placeId = group.placeId, - placeName = group.placeName + placeName = group.placeName, + photoUrl = group.placePhotoUrl ) controller.navigate(AppScreens.ChatThread.name) }, user = groupUser, - lastMessage = if (isMuted) "🔇 Silenciado" else group.lastMessage, - status = "Grupo", + lastMessage = group.lastMessage, timestamp = formatTimestamp(group.lastMessageTimestamp), hasUnread = group.unreadCount > 0, unreadCount = group.unreadCount 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 0f7c7b7..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,7 @@ package com.appnotresponding.rumbo.ui.templates import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold @@ -49,6 +50,7 @@ fun ChatThreadTemplate( }, bottomBar = { Box( modifier = Modifier + .imePadding() .navigationBarsPadding() .padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 12.dp) ) { 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 8188439..60e0545 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 @@ -305,10 +305,10 @@ fun MapTemplate( ), contentAlignment = Alignment.Center ) { IconButton(onClick = { - Log.d("MapTemplate", "Eye button clicked! Current state sharingLocation=${user.sharingLocation}, toggling to ${!user.sharingLocation}") userViewModel.toggleLocationSharing(!user.sharingLocation) }) { Icon( + modifier = Modifier.size(24.dp), painter = painterResource( if (user.sharingLocation) R.drawable.ic_eye_open else R.drawable.ic_eye_crossed @@ -316,7 +316,6 @@ fun MapTemplate( contentDescription = "Compartir ubicación", tint = if (user.sharingLocation) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp) ) } } 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 index 0be80d6..c0579ef 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt @@ -217,6 +217,7 @@ class ChatViewModel : ViewModel() { val group = GroupChat( placeId = placeId, placeName = placeName, + placePhotoUrl = place.imageUrl, lastMessage = lastMessage, lastMessageTimestamp = lastTimestamp, unreadCount = unreadCount, @@ -248,12 +249,12 @@ class ChatViewModel : ViewModel() { } } - fun selectGroupChat(placeId: String, placeName: String) { + fun selectGroupChat(placeId: String, placeName: String, photoUrl: String? = null) { _uiState.update { it.copy( selectedChatId = placeId, selectedChatTitle = placeName, - selectedChatPhoto = null, + selectedChatPhoto = photoUrl, selectedChatIsOnline = false, isGroupChat = true ) From 5302a8816ec98d0d34f1dcbce4ccaeabfcfdbe0e Mon Sep 17 00:00:00 2001 From: JDOG Date: Tue, 2 Jun 2026 19:31:18 -0500 Subject: [PATCH 34/49] [Feat]: Basic notifications working --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 11 ++++- .../appnotresponding/rumbo/MainActivity.kt | 10 ++++ .../appnotresponding/rumbo/ui/utils/MyApp.kt | 41 ++++++++++++++++ .../ui/utils/firebaseMessagingService.kt | 25 ++++++++++ .../rumbo/ui/utils/notificationUtils.kt | 48 +++++++++++++++++++ app/src/main/res/drawable/outline_tour_24.xml | 5 ++ gradle/libs.versions.toml | 2 + 8 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/utils/MyApp.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/utils/firebaseMessagingService.kt create mode 100644 app/src/main/java/com/appnotresponding/rumbo/ui/utils/notificationUtils.kt create mode 100644 app/src/main/res/drawable/outline_tour_24.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7fab7ae..49ae3ca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -78,6 +78,7 @@ dependencies { 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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0ace41c..af50f20 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,12 +8,13 @@ - + - + + + + + + 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/firebaseMessagingService.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/firebaseMessagingService.kt new file mode 100644 index 0000000..eef6522 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/firebaseMessagingService.kt @@ -0,0 +1,25 @@ +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.notification?.title + val body = remoteMessage.notification?.body + if(title != null && body != null){ + Log.i("FirebaseApp" + , title) + Log.i("FirebaseApp" + , body) +//Build and display notification with remote data + showNotification(title, body, this) + } + } +} \ 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..4e8f638 --- /dev/null +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/notificationUtils.kt @@ -0,0 +1,48 @@ +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, + targetUid: String? = null +) { + val notManager = getSystemService(context, NotificationManager::class.java) + + val intent = Intent(context, MainActivity::class.java).apply { + Log.i("NotifExp", targetUid?.toString() ?: "null") + targetUid?.let { + putExtra("targetUid", it) + } + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + val pendingIntent = PendingIntent.getActivity( + context, + targetUid?.hashCode() ?: 0, + 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.outline_tour_24) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + notManager?.notify(1, notification) +} \ No newline at end of file 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8b68376..f590106 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ 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" } @@ -65,6 +66,7 @@ play-services-maps = { module = "com.google.android.gms:play-services-maps", ver 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" } From 0727698f061528e38f0017b3402473dcf419c926 Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Tue, 2 Jun 2026 20:32:36 -0500 Subject: [PATCH 35/49] [Feat]: Enhance online presence and filter chat list by friendship --- .../rumbo/ui/components/atoms/Avatar.kt | 29 ++-- .../components/molecules/chat/ChatListItem.kt | 21 ++- .../ui/components/organisms/common/TopBar.kt | 17 ++- .../rumbo/ui/screens/chat/ChatListScreen.kt | 3 +- .../rumbo/ui/screens/chat/ChatThreadScreen.kt | 2 +- .../rumbo/ui/utils/jsonFunc.kt | 52 +++---- .../rumbo/ui/viewModel/chatThreadViewModel.kt | 47 ++++++- .../rumbo/ui/viewModel/chatViewModel.kt | 133 +++++++++--------- .../rumbo/ui/viewModel/dropNoteViewModel.kt | 3 +- .../rumbo/ui/viewModel/friendsViewModel.kt | 21 ++- .../rumbo/ui/viewModel/userViewModel.kt | 6 +- 11 files changed, 194 insertions(+), 140 deletions(-) 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 b411ee5..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 @@ -107,14 +107,8 @@ fun Avatar( Box( modifier = Modifier .fillMaxSize() - .background(color = backgroundColor, shape = CircleShape) - .then( - if (onlineBorderWidth > 0.dp) { - Modifier.border(onlineBorderWidth, onlineBorderColor, CircleShape) - } else { - Modifier - } - ), contentAlignment = Alignment.Center + .background(color = backgroundColor, shape = CircleShape), + contentAlignment = Alignment.Center ) { //Verificar si hay foto de perfil when { @@ -178,8 +172,27 @@ fun Avatar( ) } } + + 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 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 6082883..949676f 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 @@ -64,9 +65,13 @@ fun ChatListItem( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Box { - Avatar(user = user, isOnline = isOnline) - } + // 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( @@ -81,7 +86,15 @@ fun ChatListItem( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false) ) - if (status != null) { + if (isOnline) { + Text( + text = " · En línea", + style = MaterialTheme.typography.labelMedium, + color = Color(0xFF4CAF50), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } else if (status != null) { Text( text = " · ", style = MaterialTheme.typography.titleSmall, 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 5dd1e36..1ca60b7 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 @@ -98,6 +98,17 @@ fun ChatTopBar( ) { val bottomRoundedShape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) val displayName = u.name.replace(Regex(" +$"), "") + + val subtitle: String? = when { + !isGroup && isOnline -> "En línea" + !activity.isNullOrBlank() -> activity + else -> null + } + val subtitleColor = if (!isGroup && isOnline) + androidx.compose.ui.graphics.Color(0xFF4CAF50) + else + MaterialTheme.colorScheme.primary + Surface( shape = bottomRoundedShape, color = MaterialTheme.colorScheme.surfaceContainerLow, ) { @@ -134,11 +145,11 @@ fun ChatTopBar( maxLines = 1, overflow = TextOverflow.Ellipsis ) - if (!activity.isNullOrBlank()) { + if (subtitle != null) { Text( - text = activity, + text = subtitle, style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, + color = subtitleColor, maxLines = 1, overflow = TextOverflow.Ellipsis ) 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 c589fc1..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 @@ -116,7 +116,8 @@ fun ChatListScreen( id = convo.otherUserId, name = convo.otherUserName, profilePictureUrl = convo.otherUserPhotoUrl, - activity = convo.otherUserActivity + activity = convo.otherUserActivity, + isOnline = convo.isOtherUserOnline ) ChatListItem( modifier = Modifier 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 076b781..d41458c 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 @@ -150,7 +150,7 @@ fun ChatThreadScreen( chatAvatarUser = avatarUser, isGroup = isGroup, isMuted = isMuted, - isOnline = !isGroup && (otherUser?.isOnline ?: chatState.selectedChatIsOnline), + isOnline = !isGroup && threadState.otherUserIsOnline, onMuteClick = { if (isMuted) { chatViewModel.unmuteGroup(chatId) 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 index 542254b..7ce1204 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/jsonFunc.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/jsonFunc.kt @@ -1,36 +1,22 @@ package com.appnotresponding.rumbo.ui.utils -import android.content.Context -import com.appnotresponding.rumbo.models.Place -import com.appnotresponding.rumbo.models.Review -import org.json.JSONArray -import org.json.JSONObject +import com.appnotresponding.rumbo.models.User +import com.google.firebase.database.DataSnapshot -/** -fun loadPlaces(context: Context): MutableList { - val places = mutableListOf() - - val jsonString = context.assets.open("places.json").bufferedReader().use { it.readText() } - var placesJsonArray = JSONArray(jsonString) - - for (i in 0..placesJsonArray.length() - 1) { - val placeObject = placesJsonArray.getJSONObject(i) - - val id = placeObject.getString("id") - val name = placeObject.getString("name") - val description = placeObject.getString("description") - val openHours = placeObject.getString("openHours") - val price = placeObject.getString("price") - val latitude = placeObject.getDouble("latitude") - val longitude = placeObject.getDouble("longitude") - val rating = placeObject.getDouble("rating").toFloat() - val reviews = emptyList() - val image = placeObject.getString("imageUrl") - val place = Place(id, name, description, openHours, price, latitude, longitude, rating, reviews, image) - places.add(place) - } - - return places -} - - */ \ No newline at end of file +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/viewModel/chatThreadViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt index 0231f44..a5b8ae4 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt @@ -3,6 +3,7 @@ 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 @@ -21,7 +22,8 @@ data class ChatThreadState( val messages: List = emptyList(), val isSending: Boolean = false, val messageAuthors: Map = emptyMap(), - val lastReadTimestamp: Long = 0 + val lastReadTimestamp: Long = 0, + val otherUserIsOnline: Boolean = false ) class ChatThreadViewModel : ViewModel() { @@ -37,6 +39,8 @@ class ChatThreadViewModel : ViewModel() { 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() @@ -50,6 +54,27 @@ class ChatThreadViewModel : ViewModel() { userCache.clear() } + 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() @@ -64,10 +89,8 @@ class ChatThreadViewModel : ViewModel() { if (!userCache.containsKey(senderId) && !userListeners.containsKey(senderId)) { val listener = object : ValueEventListener { override fun onDataChange(userSnapshot: DataSnapshot) { - val user = userSnapshot.getValue(User::class.java) - if (user != null) { - userCache[senderId] = user - } + val user = userSnapshot.toUser(senderId) + userCache[senderId] = user pushState() } @@ -89,8 +112,16 @@ class ChatThreadViewModel : ViewModel() { 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 { @@ -132,6 +163,11 @@ class ChatThreadViewModel : ViewModel() { 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) @@ -363,5 +399,6 @@ class ChatThreadViewModel : ViewModel() { 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 index c0579ef..bdf9655 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatViewModel.kt @@ -5,6 +5,7 @@ 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 @@ -44,6 +45,9 @@ class ChatViewModel : ViewModel() { 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 @@ -61,11 +65,9 @@ class ChatViewModel : ViewModel() { if (userListeners.containsKey(otherUid)) return val userListener = object : ValueEventListener { override fun onDataChange(userSnapshot: DataSnapshot) { - val user = userSnapshot.getValue(User::class.java) - if (user != null) { - resolvedUsers[otherUid] = user - rebuildConversationsList(myUid) - } + val user = userSnapshot.toUser(otherUid) + resolvedUsers[otherUid] = user + rebuildConversationsList(myUid) } override fun onCancelled(error: DatabaseError) {} } @@ -83,88 +85,72 @@ class ChatViewModel : ViewModel() { return } - var pending = children.size - if (pending == 0) { - _uiState.update { it.copy(directChats = emptyList()) } - return - } - for (child in children) { - val chatId = child.key - if (chatId == null) { - pending-- - if (pending == 0) { - _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } - } - continue - } + val chatId = child.key ?: continue val participants = child.child("participants").children.map { it.value as? String ?: "" } - if (!participants.contains(myUid)) { - pending-- - if (pending == 0) { - _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } - } - continue - } - val otherUid = participants.firstOrNull { it != myUid } - if (otherUid == null) { - pending-- - if (pending == 0) { - _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } - } - continue - } + 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) - db.getReference("friendships").child(myUid).child(otherUid).get().addOnSuccessListener { friendshipSnap -> - val areFriends = friendshipSnap.exists() && friendshipSnap.value == true - if (!areFriends) { - pending-- - if (pending == 0) { - _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } - } - return@addOnSuccessListener - } - - val user = resolvedUsers[otherUid] - if (user != null) { - conversations.add( - ChatConversation( - chatId = chatId, - otherUserId = otherUid, - otherUserName = user.name, - otherUserPhotoUrl = user.profilePictureUrl, - otherUserActivity = user.activity, - isOtherUserOnline = user.isOnline, - lastMessage = lastMessage, - lastMessageTimestamp = lastTimestamp, - unreadCount = unreadCount - ) + val user = resolvedUsers[otherUid] + if (user != null) { + conversations.add( + ChatConversation( + chatId = chatId, + otherUserId = otherUid, + otherUserName = user.name, + otherUserPhotoUrl = user.profilePictureUrl, + otherUserActivity = user.activity, + isOtherUserOnline = user.isOnline, + lastMessage = lastMessage, + lastMessageTimestamp = lastTimestamp, + unreadCount = unreadCount ) - } else { - setupUserListener(otherUid, myUid) - } - pending-- - if (pending == 0) { - _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } - } - }.addOnFailureListener { - pending-- - if (pending == 0) { - _uiState.update { it.copy(directChats = conversations.sortedByDescending { c -> c.lastMessageTimestamp }) } - } + ) + } + } + _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) } @@ -285,6 +271,13 @@ class ChatViewModel : ViewModel() { } 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) } 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 index 9572635..6260474 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/dropNoteViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/dropNoteViewModel.kt @@ -96,7 +96,8 @@ class DropNoteViewModel : ViewModel() { val user = userSnapshot.getValue(User::class.java) Log.d(TAG, "Usuario parseado[$creatorId]: $user") if (user != null) { - userCache[creatorId] = user + 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") 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 index f1fbdca..af2e5a2 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/friendsViewModel.kt @@ -2,6 +2,7 @@ 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 @@ -85,11 +86,9 @@ class FriendsViewModel : ViewModel() { for (friendId in friendIds) { val listener = object : ValueEventListener { override fun onDataChange(snapshot: DataSnapshot) { - val user = snapshot.getValue(User::class.java) - if (user != null) { - friendsMap[friendId] = user - _uiState.update { it.copy(friends = friendsMap.values.toList()) } - } + val user = snapshot.toUser(friendId) + friendsMap[friendId] = user + _uiState.update { it.copy(friends = friendsMap.values.toList()) } } override fun onCancelled(error: DatabaseError) {} @@ -140,11 +139,9 @@ class FriendsViewModel : ViewModel() { for (senderId in senderIds) { val listener = object : ValueEventListener { override fun onDataChange(snapshot: DataSnapshot) { - val user = snapshot.getValue(User::class.java) - if (user != null) { - requestUsersMap[senderId] = user - _uiState.update { it.copy(pendingRequests = requestUsersMap.values.toList()) } - } + val user = snapshot.toUser(senderId) + requestUsersMap[senderId] = user + _uiState.update { it.copy(pendingRequests = requestUsersMap.values.toList()) } } override fun onCancelled(error: DatabaseError) {} } @@ -163,7 +160,7 @@ class FriendsViewModel : ViewModel() { dbUsers.get().addOnSuccessListener { snapshot -> val results = mutableListOf() for (child in snapshot.children) { - val user = child.getValue(User::class.java) ?: continue + 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) @@ -200,7 +197,7 @@ class FriendsViewModel : ViewModel() { dbUsers.get().addOnSuccessListener { snapshot -> val results = mutableListOf() for (child in snapshot.children) { - val user = child.getValue(User::class.java) ?: continue + val user = child.toUser(child.key ?: "") val userEmail = user.email.lowercase().trim() val userPhone = normalizePhone(user.phone) val matchesEmail = userEmail.isNotBlank() && normalizedEmails.contains(userEmail) diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt index 6e164a9..bc7f0b0 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/userViewModel.kt @@ -2,6 +2,7 @@ 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 @@ -44,6 +45,7 @@ class UserViewModel : ViewModel() { 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) {} @@ -57,8 +59,8 @@ class UserViewModel : ViewModel() { dbRef.child(uid).addValueEventListener(object : ValueEventListener { override fun onDataChange(snapshot: DataSnapshot) { try { - val user = snapshot.getValue(User::class.java) - android.util.Log.d("UserViewModel", "fetchUserData success: user=${user?.name}, sharingLocation=${user?.sharingLocation}") + 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) From 2fcd037288626e75c668b7a675b7a59028897839 Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Tue, 2 Jun 2026 20:42:25 -0500 Subject: [PATCH 36/49] [Fix]: Online and Status clash --- .../rumbo/ui/components/molecules/chat/ChatListItem.kt | 10 +--------- .../rumbo/ui/components/organisms/common/TopBar.kt | 2 +- 2 files changed, 2 insertions(+), 10 deletions(-) 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 949676f..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 @@ -86,15 +86,7 @@ fun ChatListItem( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false) ) - if (isOnline) { - Text( - text = " · En línea", - style = MaterialTheme.typography.labelMedium, - color = Color(0xFF4CAF50), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } else if (status != null) { + if (status != null && isOnline) { Text( text = " · ", style = MaterialTheme.typography.titleSmall, 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 1ca60b7..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 @@ -100,8 +100,8 @@ fun ChatTopBar( val displayName = u.name.replace(Regex(" +$"), "") val subtitle: String? = when { - !isGroup && isOnline -> "En línea" !activity.isNullOrBlank() -> activity + !isGroup && isOnline -> "En línea" else -> null } val subtitleColor = if (!isGroup && isOnline) From 5233dcbde6f70be88d7d800c276971c01faeff21 Mon Sep 17 00:00:00 2001 From: JDOG Date: Tue, 2 Jun 2026 21:15:34 -0500 Subject: [PATCH 37/49] [Feat]: Notifications working with message from chat --- .../appnotresponding/rumbo/models/logIn.kt | 22 +++++++++++++++++++ .../rumbo/models/registerViewModel.kt | 13 +++++++++++ .../rumbo/ui/screens/profile/ProfileScreen.kt | 17 +++++++++++++- .../ui/utils/firebaseMessagingService.kt | 6 ++--- .../rumbo/ui/utils/notificationUtils.kt | 2 +- app/src/main/res/drawable/brand.xml | 5 +++++ 6 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 app/src/main/res/drawable/brand.xml diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt b/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt index 0e34b6e..c83b307 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt @@ -14,6 +14,10 @@ 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 { @@ -114,6 +118,15 @@ class LoginViewModel : ViewModel() { _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 -> @@ -168,6 +181,15 @@ class LoginViewModel : ViewModel() { } private fun firebaseSignIn(email: String, password: String) { + 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.Loading) } auth.signInWithEmailAndPassword(email, password).addOnSuccessListener { _loginState.update { it.copy(authResult = AuthResult.Success) } diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/registerViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/models/registerViewModel.kt index 62fd9e3..76f96c0 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/registerViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/registerViewModel.kt @@ -14,8 +14,12 @@ 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 = "", @@ -176,6 +180,15 @@ class RegisterViewModel : ViewModel() { 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() 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 index 84ac8bd..5cba043 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -21,8 +22,12 @@ 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( @@ -74,7 +79,17 @@ fun ProfileScreen( onSuccess = { selectedPhotoUri = null }) }, onSignOut = { - auth.signOut() + 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/utils/firebaseMessagingService.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/firebaseMessagingService.kt index eef6522..8217316 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/firebaseMessagingService.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/firebaseMessagingService.kt @@ -11,14 +11,14 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { Log.i("FirebaseApp" , "Message Received!!!") - val title = remoteMessage.notification?.title - val body = remoteMessage.notification?.body + val title = remoteMessage.data["title"] + val body = remoteMessage.data["body"] + val senderId = remoteMessage.data["senderId"] if(title != null && body != null){ Log.i("FirebaseApp" , title) Log.i("FirebaseApp" , body) -//Build and display notification with remote data showNotification(title, body, this) } } 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 index 4e8f638..6618ea0 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/notificationUtils.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/notificationUtils.kt @@ -39,7 +39,7 @@ fun showNotification( ) .setContentTitle(title) .setContentText(message) - .setSmallIcon(R.drawable.outline_tour_24) + .setSmallIcon(R.drawable.brand) .setAutoCancel(true) .setContentIntent(pendingIntent) .build() 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 @@ + + + + + From 0d8691d365c78cb6326bd766774f88bb274c0954 Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Tue, 2 Jun 2026 21:32:32 -0500 Subject: [PATCH 38/49] feat: Implement Google Maps HeatmapTileProvider Replaces the custom circle-based heatmap with the dedicated HeatmapTileProvider. This provides a more accurate, performant, and visually appealing representation of data density. The MapState now exposes raw LatLng points directly for the provider. --- .../rumbo/ui/templates/MapTemplate.kt | 52 ++++++++++++------- .../rumbo/ui/viewModel/mapViewModel.kt | 5 +- 2 files changed, 35 insertions(+), 22 deletions(-) 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 666d09c..c943258 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 @@ -36,7 +36,6 @@ 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.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext @@ -100,6 +99,10 @@ 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.Color +import androidx.compose.ui.graphics.toArgb import org.osmdroid.util.GeoPoint @@ -163,6 +166,30 @@ fun MapTemplate( 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 { @@ -345,25 +372,10 @@ fun MapTemplate( ) } - if (state.isHeatmapVisible && state.heatmapClusters.isNotEmpty()) { - state.heatmapClusters.forEach { cluster -> - val color = when { - cluster.count == 1 -> androidx.compose.ui.graphics.Color.Cyan - cluster.count == 2 -> androidx.compose.ui.graphics.Color.Green - cluster.count == 3 -> androidx.compose.ui.graphics.Color(0xFFFFA500) // Naranja - else -> androidx.compose.ui.graphics.Color.Red - } - val radius = when { - cluster.count == 1 -> 30.0 - cluster.count == 2 -> 60.0 - cluster.count == 3 -> 90.0 - else -> 120.0 - } - com.google.maps.android.compose.Circle( - center = cluster.position, - fillColor = color.copy(alpha = 0.5f), - strokeColor = color, - radius = radius + if (state.isHeatmapVisible && heatmapTileProvider != null) { + key(heatmapTileProvider) { + TileOverlay( + tileProvider = heatmapTileProvider ) } } 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 index 2287267..6797f4f 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/mapViewModel.kt @@ -22,7 +22,8 @@ data class MapState( val centerInUserFirstTime: Boolean = true, val lastSafeLatLng: LatLng = LatLng(0.0, 0.0), val isHeatmapVisible: Boolean = false, - val heatmapClusters: List = emptyList() + val heatmapClusters: List = emptyList(), + val heatmapPoints: List = emptyList() ) class MapViewModel : ViewModel() { @@ -69,7 +70,7 @@ class MapViewModel : ViewModel() { } // Allow isolated users (count >= 1) - _uiState.update { it.copy(heatmapClusters = clusters) } + _uiState.update { it.copy(heatmapClusters = clusters, heatmapPoints = points) } } override fun onCancelled(error: com.google.firebase.database.DatabaseError) { From 0e7e5aed30194720594aa9a0da823919085d5f0a Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Tue, 2 Jun 2026 22:48:22 -0500 Subject: [PATCH 39/49] [feat]: Implement comprehensive POI search and enhanced map interaction Adds text-based place search functionality to the Plan screen, allowing users to find POIs. Enables discovering points of interest by long-pressing the map, which triggers a search for the nearest place within a dynamic radius based on the current zoom level. Further enhancements include: - Integrates "Add/Remove from Itinerary" actions into the map preview card and itinerary item cards. - Introduces confirmation dialogs when attempting to start a new route while another is active, preventing accidental route changes. - Activates the "Write Review" button only when the user is within proximity (100m) of the previewed place, ensuring contextual reviews. - Refactors and improves the UI for `ItineraryItemCard`, `PlanItemCard`, and `POICardReview` for better consistency and user experience across the app. - Updates `PlaceState` and `PlacesViewModel` to manage search queries and dynamically merge reviews into place previews. --- .../rumbo/models/placeState.kt | 3 +- .../molecules/itinerary/ItineraryItemCard.kt | 195 ++++++++++++++---- .../components/molecules/map/PlaceReview.kt | 21 +- .../components/molecules/plan/PlanItemCard.kt | 131 +++++++----- .../components/organisms/map/POICardRview.kt | 135 +++++++++++- .../rumbo/ui/templates/MapTemplate.kt | 89 ++++++-- .../rumbo/ui/templates/PlanTemplate.kt | 30 +++ .../rumbo/ui/utils/placesAPI.kt | 114 +++++++++- .../rumbo/ui/viewModel/placesViewModel.kt | 69 ++++++- 9 files changed, 661 insertions(+), 126 deletions(-) diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt index 5fc091c..3730952 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/placeState.kt @@ -4,5 +4,6 @@ data class PlaceState( val availablePlaces: List = emptyList(), val itinerary: List = emptyList(), val selectedPlace: Place? = null, - val previewedPlace: Place? = null + val previewedPlace: Place? = null, + val searchQuery: String = "" ) \ No newline at end of file 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 79613a6..d0882d1 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 @@ -14,7 +14,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.ui.Modifier +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.CardDefaults +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale @@ -25,6 +33,12 @@ import coil3.compose.SubcomposeAsyncImage import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.models.Place import com.appnotresponding.rumbo.navigation.AppScreens +import androidx.compose.foundation.BorderStroke +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 com.appnotresponding.rumbo.ui.components.atoms.RumboButton import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel @@ -42,53 +56,148 @@ import java.util.Locale */ @Composable 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 + ) { + Row( 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 + .fillMaxWidth() + .padding(12.dp) ) { - 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 + 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) ) - }) - } - 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 = formatOpenHours(p.openHours), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground - ) - RumboButton( - text = "Iniciar Desplazamiento", onClick = { - placesViewModel.selectForNavigation(p) - controller.navigate(AppScreens.Map.name) - }, style = RumboButtonStyle.Secondary, icon = painterResource(R.drawable.ic_map) - ) + 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 + ) + 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( + imageVector = Icons.Default.Delete, + contentDescription = "Eliminar del Itinerario", + tint = MaterialTheme.colorScheme.error + ) + } + } + } } } 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 65e2bdd..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 @@ -38,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, onNavigateClick: () -> Unit = {}, onReviewClick: () -> Unit = {}) { +fun PlaceInfo( + p: Place, + onNavigateClick: () -> Unit = {}, + onReviewClick: () -> Unit = {}, + onAddToItineraryClick: () -> Unit = {}, + isInItinerary: Boolean = false, + isReviewEnabled: Boolean = true +) { Row( modifier = Modifier .fillMaxWidth() @@ -113,12 +120,22 @@ fun PlaceInfo(p: Place, onNavigateClick: () -> Unit = {}, onReviewClick: () -> U 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) + icon = painterResource(R.drawable.ic_text_box_edit), + enabled = isReviewEnabled ) } } 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 63a9049..c6d4a7d 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 @@ -23,6 +23,10 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.foundation.clickable +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.CardDefaults +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width import androidx.navigation.NavHostController import com.appnotresponding.rumbo.navigation.AppScreens import coil3.compose.SubcomposeAsyncImage @@ -30,6 +34,7 @@ import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.models.Place import com.appnotresponding.rumbo.ui.components.atoms.RumboButton import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle +import com.appnotresponding.rumbo.ui.components.atoms.RumboRatingDisplay import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel /** @@ -48,65 +53,81 @@ fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel, controller: NavHost 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( + 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 + ) { + Row( 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) - .clickable { - placesViewModel.showPreview(p) - placesViewModel.selectForNavigation(p) - controller.navigate(AppScreens.Map.name) - }, - contentAlignment = Alignment.Center + .fillMaxWidth() + .padding(12.dp) ) { - 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 + 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 ) - }) - } - 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.description ?: "No hay descripción", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground - )/*Text( - text = p.price ?: "No hay información", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground - )*/ + } + Text( + text = p.description ?: "No hay descripción", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground + ) - RumboButton( - text = msg, onClick = { - if (isInItinerary) { - placesViewModel.removeFromItinerary(p) - } else { - placesViewModel.addToItinerary(p) - } - }, style = RumboButtonStyle.Secondary, icon = painterResource(icon) - ) + RumboButton( + text = msg, onClick = { + if (isInItinerary) { + placesViewModel.removeFromItinerary(p) + } else { + placesViewModel.addToItinerary(p) + } + }, style = RumboButtonStyle.Secondary, icon = painterResource(icon) + ) + } } } 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 fed4075..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 @@ -20,21 +20,50 @@ 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. * @param place El objeto Place que contiene la información del lugar a mostrar * @param reviews La lista de objetos Review que contienen las reseñas asociadas al lugar */ +@Composable +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 = {} + onReviewClick: () -> Unit = {}, + onAddToItineraryClick: () -> Unit = {}, + isInItinerary: Boolean = false, + isReviewEnabled: Boolean = true ) { Surface(shape = MaterialTheme.shapes.large) { Column( @@ -42,21 +71,117 @@ fun PlacePreviewCard( .fillMaxWidth() .padding(vertical = 8.dp) ) { - PlaceInfo(p = place, onNavigateClick = onNavigateClick, onReviewClick = onReviewClick) + + 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/templates/MapTemplate.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/MapTemplate.kt index c943258..81f8212 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 @@ -106,10 +106,20 @@ import androidx.compose.ui.graphics.toArgb 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( @@ -142,6 +152,18 @@ fun MapTemplate( 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) } @@ -328,6 +350,16 @@ fun MapTemplate( 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, @@ -380,17 +412,7 @@ fun MapTemplate( } } - placesState.availablePlaces.forEach { place -> - val position = LatLng(place.latitude, place.longitude) - Marker( - state = rememberUpdatedMarkerState(position), - title = place.name, - onClick = { - placesViewModel.showPreview(place) - true - } - ) - } + dropNoteState.dropNotes.forEach { note -> val position = LatLng(note.latitude, note.longitude) @@ -534,12 +556,26 @@ fun MapTemplate( indication = null, onClick = {} )) { + val isInItinerary = placesState.itinerary.any { it.id == currentPreviewedPlace.id } PlacePreviewCard( place = currentPreviewedPlace, reviews = currentPreviewedPlace.reviews, + isInItinerary = isInItinerary, + isReviewEnabled = isWithinProximity, onNavigateClick = { - placesViewModel.selectForNavigation(currentPreviewedPlace) - placesViewModel.showPreview(null) + 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 @@ -647,4 +683,29 @@ fun MapTemplate( } } } + 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 0d6830f..0e920b9 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 @@ -18,6 +18,15 @@ 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.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 /** * @@ -38,6 +47,9 @@ fun PlanTemplate( onProfileClick: () -> Unit = {}, placesViewModel: PlacesViewModel ) { + val placesState by placesViewModel.uiState.collectAsState() + val context = LocalContext.current + Scaffold( contentWindowInsets = WindowInsets(0), topBar = { MainTopBar(u = user, onProfileClick = onProfileClick) }, @@ -49,6 +61,24 @@ fun PlanTemplate( ) { 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, placesViewModel, controller) 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 index d32afef..74d7e58 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/placesAPI.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/placesAPI.kt @@ -17,7 +17,8 @@ fun searchNearbyPlaces( longitude: Double, onPlacesReceived: (List) -> Unit, onError: (String) -> Unit, - context: Context + context: Context, + radius: Double = 5000.0 ) { val apiKey = BuildConfig.MAPS_API_KEY val requestQueue = Volley.newRequestQueue(context) @@ -53,7 +54,7 @@ fun searchNearbyPlaces( } ) - put("radius", 5000.0) + put("radius", radius) } ) } @@ -243,3 +244,112 @@ fun searchNearbyPlaces( 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/placesViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt index e4c8867..f0eed37 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt @@ -2,6 +2,7 @@ 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 @@ -12,7 +13,8 @@ class PlacesViewModel : ViewModel() { private val _uiState = MutableStateFlow(PlaceState()) val uiState: StateFlow = _uiState.asStateFlow() - private var rawPlaces: List = emptyList() + private var rawNearbyPlaces: List = emptyList() + private var rawSearchPlaces: List = emptyList() private var firebaseReviews: Map> = emptyMap() init { @@ -44,13 +46,14 @@ class PlacesViewModel : ViewModel() { } fun updatePlaces(list: List) { - rawPlaces = list + rawNearbyPlaces = list mergePlacesWithReviews() } private fun mergePlacesWithReviews() { _uiState.update { currentState -> - val merged = rawPlaces.map { place -> + 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) @@ -66,6 +69,26 @@ class PlacesViewModel : ViewModel() { } } + 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 }) { @@ -85,7 +108,45 @@ class PlacesViewModel : ViewModel() { } fun showPreview(place: Place?) { - _uiState.update { it.copy(previewedPlace = 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() { From e67f7ce08e8ba5eb6d5fb455394a7fb8c887250f Mon Sep 17 00:00:00 2001 From: JDOG Date: Tue, 2 Jun 2026 22:49:09 -0500 Subject: [PATCH 40/49] [Feat]: Notification navigate directly to chat --- .../appnotresponding/rumbo/MainActivity.kt | 22 +++++++++++++- .../rumbo/navigation/navigation.kt | 29 ++++++++++++++++++- .../ui/utils/firebaseMessagingService.kt | 6 +++- .../rumbo/ui/utils/notificationUtils.kt | 29 +++++++++++++++---- 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt index 53f670e..c686a91 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/MainActivity.kt @@ -1,5 +1,6 @@ package com.appnotresponding.rumbo +import android.content.Intent import android.location.Geocoder import android.hardware.Sensor import android.hardware.SensorEvent @@ -54,6 +55,13 @@ class MainActivity : FragmentActivity(), SensorEventListener { 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/ @@ -63,7 +71,14 @@ class MainActivity : FragmentActivity(), SensorEventListener { }.build() } RumboTheme(darkTheme = isDarkTheme) { - Navigation() + Navigation( + openChat = openChat, + senderId = senderId, + chatId = chatId, + senderName = senderName, + senderPhotoUrl = senderPhotoUrl, + isOnline = isOnline + ) } } } @@ -88,4 +103,9 @@ class MainActivity : FragmentActivity(), SensorEventListener { } 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/navigation/navigation.kt b/app/src/main/java/com/appnotresponding/rumbo/navigation/navigation.kt index 5a8fd6b..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,6 +1,7 @@ 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 @@ -44,10 +45,36 @@ enum class AppScreens { @Composable 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() + userViewModel: UserViewModel = viewModel(), ) { val navController = rememberNavController() + + 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) 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 index 8217316..430da91 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/firebaseMessagingService.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/firebaseMessagingService.kt @@ -14,12 +14,16 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { 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) + 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/notificationUtils.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/notificationUtils.kt index 6618ea0..e2ac85b 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/utils/notificationUtils.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/utils/notificationUtils.kt @@ -14,21 +14,38 @@ fun showNotification( title: String, message: String, context: Context, - targetUid: String? = null + 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 { - Log.i("NotifExp", targetUid?.toString() ?: "null") - targetUid?.let { - putExtra("targetUid", it) + 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, - targetUid?.hashCode() ?: 0, + requestCode, intent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE ) From 73722616253d9ba83729fb55120de2b969e0457d Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Tue, 2 Jun 2026 23:02:49 -0500 Subject: [PATCH 41/49] [Feat]: Minor UI --- .gitignore | 2 + .../molecules/itinerary/ItineraryItemCard.kt | 220 +++++++++--------- .../components/molecules/plan/PlanItemCard.kt | 142 ++++++----- 3 files changed, 181 insertions(+), 183 deletions(-) diff --git a/.gitignore b/.gitignore index e5cbb64..be8fce6 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ google-services.json # Android Profiling *.hprof + +.codegraph/ 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 d0882d1..1d1e6fe 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 @@ -6,23 +6,28 @@ 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.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +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.material3.ElevatedCard -import androidx.compose.material3.CardDefaults -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale @@ -33,12 +38,6 @@ import coil3.compose.SubcomposeAsyncImage import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.models.Place import com.appnotresponding.rumbo.navigation.AppScreens -import androidx.compose.foundation.BorderStroke -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 com.appnotresponding.rumbo.ui.components.atoms.RumboButton import com.appnotresponding.rumbo.ui.components.atoms.RumboButtonStyle import com.appnotresponding.rumbo.ui.viewModel.PlacesViewModel @@ -64,7 +63,9 @@ fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel, controller: Na if (showReplaceRouteDialog) { androidx.compose.material3.AlertDialog( - onDismissRequest = { showReplaceRouteDialog = false }, + onDismissRequest = { + showReplaceRouteDialog = false + }, title = { Text("Ruta activa") }, text = { Text("Tienes una ruta activa en curso. ¿Deseas reemplazarla?") }, confirmButton = { @@ -73,24 +74,20 @@ fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel, controller: Na showReplaceRouteDialog = false placesViewModel.selectForNavigation(p) controller.navigate(AppScreens.Map.name) - } - ) { + }) { Text("Confirmar") } }, dismissButton = { androidx.compose.material3.TextButton( - onClick = { showReplaceRouteDialog = false } - ) { + onClick = { showReplaceRouteDialog = false }) { Text("Cancelar") } - } - ) + }) } ElevatedCard( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.elevatedCardColors( + modifier = Modifier.fillMaxWidth(), colors = CardDefaults.elevatedCardColors( containerColor = if (isActiveRoute) { MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) } else { @@ -100,104 +97,103 @@ fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel, controller: Na elevation = CardDefaults.elevatedCardElevation( defaultElevation = if (isActiveRoute) 4.dp else 2.dp - ), - shape = MaterialTheme.shapes.medium + ), shape = MaterialTheme.shapes.medium ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - ) { - 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( + Column { + Row( modifier = Modifier - .weight(6f), - verticalArrangement = Arrangement.spacedBy(8.dp) + .fillMaxWidth() + .padding(12.dp) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + Box( + modifier = Modifier + .weight(4f) + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center ) { - 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 + 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 ) - } - } + }) } - Text( - text = formatOpenHours(p.openHours), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier.weight(6f), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - 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( - imageVector = Icons.Default.Delete, - contentDescription = "Eliminar del Itinerario", - tint = MaterialTheme.colorScheme.error + 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 + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) {} } } + RumboButton( + modifier = Modifier.weight(1f).fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + 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( + imageVector = Icons.Default.Delete, + contentDescription = "Eliminar del Itinerario", + tint = MaterialTheme.colorScheme.error + ) + } } } @@ -212,7 +208,9 @@ fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel, controller: Na * - 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 { +fun formatOpenHours( + openHours: List?, now: LocalDateTime = LocalDateTime.now() +): String { if (openHours.isNullOrEmpty()) { return "No hay información de horario" } 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 c6d4a7d..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,14 +2,19 @@ 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 @@ -22,16 +27,11 @@ 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 androidx.compose.foundation.clickable -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.CardDefaults -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width import androidx.navigation.NavHostController -import com.appnotresponding.rumbo.navigation.AppScreens import coil3.compose.SubcomposeAsyncImage import com.appnotresponding.rumbo.R import com.appnotresponding.rumbo.models.Place +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.components.atoms.RumboRatingDisplay @@ -54,83 +54,81 @@ fun PlanItemCard(p: Place, placesViewModel: PlacesViewModel, controller: NavHost val msg = if (isInItinerary) "Eliminar del Itinerario" else "Añadir al Itinerario" ElevatedCard( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.elevatedCardColors( + modifier = Modifier.fillMaxWidth(), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ), - elevation = CardDefaults.elevatedCardElevation( + ), elevation = CardDefaults.elevatedCardElevation( defaultElevation = 2.dp - ), - shape = MaterialTheme.shapes.medium + ), shape = MaterialTheme.shapes.medium ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - ) { - Box( + Column { + Row( 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 + .fillMaxWidth() + .padding(12.dp) ) { - 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 + 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 ) - }) - } - 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 ) - } - Text( - text = p.description ?: "No hay descripción", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground - ) - RumboButton( - text = msg, onClick = { - if (isInItinerary) { - placesViewModel.removeFromItinerary(p) - } else { - placesViewModel.addToItinerary(p) - } - }, style = RumboButtonStyle.Secondary, icon = painterResource(icon) - ) + } } } + RumboButton( + modifier = Modifier + .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) + ) } - } /** From 6359c461720542c4e6707f3cd33bc4cbd58cc0a4 Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Wed, 3 Jun 2026 00:17:53 -0500 Subject: [PATCH 42/49] [Fix]: Biometric Fix --- .../com/appnotresponding/rumbo/models/logIn.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt b/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt index c83b307..26df815 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/models/logIn.kt @@ -181,17 +181,17 @@ class LoginViewModel : ViewModel() { } private fun firebaseSignIn(email: String, password: String) { - 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.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() From bad35b58c21b63b10d78209de43387e79580de2b Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Wed, 3 Jun 2026 00:29:50 -0500 Subject: [PATCH 43/49] [Feat]: Add expandable FAB for map controls Consolidates multiple map-related buttons (heatmap, drop note, locate me, and user route visibility) into a single, animated, expandable Floating Action Button. This improves UI organization and reduces clutter on the map screen, enhancing the overall user experience. A new 'Toggle User Route' component is also introduced and integrated into this FAB. --- .../molecules/map/MapFloatingActions.kt | 85 +++++++++++++++++++ .../rumbo/ui/templates/MapTemplate.kt | 59 +++++-------- 2 files changed, 104 insertions(+), 40 deletions(-) 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 2dc913b..375a3d8 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 @@ -110,6 +121,80 @@ fun ToggleHeatmap(isHeatmapActive: Boolean = false, onClick: () -> Unit = {}) { } } +@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() { 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 f29c981..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 @@ -109,6 +109,7 @@ 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 @@ -349,49 +350,27 @@ fun MapTemplate( viewModel.cancelAdditionalMarkerVisibility() } } - ToggleHeatmap( + ExpandableFAB( isHeatmapActive = state.isHeatmapVisible, - onClick = { viewModel.toggleHeatmap() } - ) - WriteDropNote { - popupStateDNComposer = !popupStateDNComposer - } - LocateMe { - if (locationState.hasPermission) { - cameraPositionState.position = - CameraPosition.fromLatLngZoom(state.userMarker.position, 16f) - Log.d( - "MapTemplate", - "Ubicacion: ${locationState.latitude}, ${locationState.longitude}" - ) - } else { - locationState.requestPermission() - } - } - Box( - modifier = Modifier - .aspectRatio(1f) - .clip(CircleShape) - .background( - if (user.sharingLocation) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surfaceVariant - ), contentAlignment = Alignment.Center - ) { - IconButton(onClick = { + 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) - }) { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource( - if (user.sharingLocation) R.drawable.ic_eye_open - else R.drawable.ic_eye_crossed - ), - contentDescription = "Compartir ubicación", - tint = if (user.sharingLocation) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurfaceVariant, - ) } - } + ) } } }, From f431fcdd4edaa023adcb84b36a698941b0cbe498 Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Wed, 3 Jun 2026 00:32:27 -0500 Subject: [PATCH 44/49] [Fix]: Refine ItineraryItemCard layout Integrates the navigation and delete action buttons into the main content row, improving the card's visual structure and user experience. --- .../molecules/itinerary/ItineraryItemCard.kt | 178 +++++++++--------- 1 file changed, 90 insertions(+), 88 deletions(-) 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 1d1e6fe..d31663e 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,5 +1,6 @@ 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 @@ -87,112 +88,113 @@ fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel, controller: Na } ElevatedCard( - modifier = Modifier.fillMaxWidth(), colors = CardDefaults.elevatedCardColors( + 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 + ), + shape = MaterialTheme.shapes.medium ) { - Column { - Row( + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + Box( modifier = Modifier - .fillMaxWidth() - .padding(12.dp) + .weight(4f) + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center ) { - 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) ) { - 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 + 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 ) - }) + } + } } - Spacer(modifier = Modifier.width(12.dp)) - Column( - modifier = Modifier.weight(6f), verticalArrangement = Arrangement.spacedBy(8.dp) + Text( + text = formatOpenHours(p.openHours), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - 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 - ) + 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) } - } - } - Text( - text = formatOpenHours(p.openHours), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground + }, + style = if (isActiveRoute) RumboButtonStyle.Primary else RumboButtonStyle.Secondary, + icon = painterResource(R.drawable.ic_map) ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) {} - } - } - RumboButton( - modifier = Modifier.weight(1f).fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - 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) + IconButton(onClick = { placesViewModel.removeFromItinerary(p) }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Eliminar del Itinerario", + tint = MaterialTheme.colorScheme.error + ) } - }, - style = if (isActiveRoute) RumboButtonStyle.Primary else RumboButtonStyle.Secondary, - icon = painterResource(R.drawable.ic_map) - ) - IconButton(onClick = { placesViewModel.removeFromItinerary(p) }) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Eliminar del Itinerario", - tint = MaterialTheme.colorScheme.error - ) + } } } } From 03665ee218c2b013d7f2ddd47345c968d34de0bb Mon Sep 17 00:00:00 2001 From: JDOG Date: Wed, 3 Jun 2026 00:43:55 -0500 Subject: [PATCH 45/49] [Fix]: Chat lost messages fixed --- .../rumbo/ui/viewModel/chatThreadViewModel.kt | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) 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 index a5b8ae4..e1ec84d 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/chatThreadViewModel.kt @@ -46,12 +46,16 @@ class ChatThreadViewModel : ViewModel() { 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) { @@ -75,6 +79,8 @@ class ChatThreadViewModel : ViewModel() { ref.addValueEventListener(listener) } + + /** private fun resolveUsersAndEmit(rawMessages: List, extraUid: String? = null) { val uniqueSenderIds = (rawMessages.map { it.senderId } + listOfNotNull(extraUid)).distinct() @@ -102,10 +108,66 @@ class ChatThreadViewModel : ViewModel() { } 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) } } From 9246d3bd906fd8fbc736b454f3f8123ee930497e Mon Sep 17 00:00:00 2001 From: Alejandra Garcia Date: Wed, 3 Jun 2026 00:44:16 -0500 Subject: [PATCH 46/49] [feat]: add itinerary persistence --- .../appnotresponding/rumbo/models/place.kt | 22 ++--- .../rumbo/ui/templates/ProfileTemplate.kt | 51 +++++++---- .../rumbo/ui/viewModel/ProfileViewModel.kt | 21 +++-- .../rumbo/ui/viewModel/placesViewModel.kt | 87 ++++++++++++++++++- 4 files changed, 140 insertions(+), 41 deletions(-) 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 d2947cc..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,17 +1,17 @@ package com.appnotresponding.rumbo.models data class Place( - val id: String, - val name: String, - val address: String, - val description: String?, - val openHours: List?, - val price: String?, - val latitude: Double, - val longitude: Double, - val rating: Double?, - 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( 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 index 47899db..53d82b9 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ProfileTemplate.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ProfileTemplate.kt @@ -72,7 +72,7 @@ enum class ProfileMenu { @Composable fun ProfileTemplate( user: User, - itineraryHistory: Map>>, + itineraryHistory: Map>, dropNotes: List, selectedPhotoUri: Uri?, isSavingProfile: Boolean, @@ -312,7 +312,7 @@ private fun EditProfileSection( @Composable private fun ItineraryHistorySection( - itineraryHistory: Map>> + itineraryHistory: Map> ) { if (itineraryHistory.isEmpty()) { EmptyState(text = "Aún no hay lugares visitados.") @@ -320,29 +320,34 @@ private fun ItineraryHistorySection( } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - itineraryHistory.forEach { (cityKey, days) -> + itineraryHistory.toSortedMap(compareByDescending { it }).forEach { (day, places) -> ElevatedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { - val cityName = days.values.flatten().firstOrNull()?.city ?: cityKey - Text( - text = cityName, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.height(12.dp)) - days.toSortedMap(compareByDescending { it }).forEach { (day, places) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { Text( - text = day, - style = MaterialTheme.typography.titleSmall, + text = formatDateHeader(day), + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) - Spacer(modifier = Modifier.height(8.dp)) - places.forEach { place -> - VisitedPlaceRow(place) + 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)) } - HorizontalDivider() - Spacer(modifier = Modifier.height(12.dp)) } } } @@ -350,6 +355,16 @@ private fun ItineraryHistorySection( } } +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( 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 index a4b6b2c..ff334d9 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/ProfileViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/ProfileViewModel.kt @@ -20,7 +20,7 @@ data class ProfileState( val isSavingProfile: Boolean = false, val profileError: String = "", val profileSuccess: String = "", - val itineraryHistory: Map>> = emptyMap(), + val itineraryHistory: Map> = emptyMap(), val userDropNotes: List = emptyList() ) @@ -119,22 +119,25 @@ class ProfileViewModel : ViewModel() { loadedHistoryUid = uid val listener = object : ValueEventListener { override fun onDataChange(snapshot: DataSnapshot) { - val grouped = linkedMapOf>>() + val dayMap = mutableMapOf>() snapshot.children.forEach { citySnapshot -> - val days = linkedMapOf>() citySnapshot.children.forEach { daySnapshot -> + val dayKey = daySnapshot.key ?: "Sin fecha" val places = daySnapshot.children.mapNotNull { it.getValue(VisitedPlace::class.java) - }.sortedByDescending { it.visitedAt } + } if (places.isNotEmpty()) { - days[daySnapshot.key ?: "Sin fecha"] = places + val list = dayMap.getOrPut(dayKey) { mutableListOf() } + list.addAll(places) } } - if (days.isNotEmpty()) { - grouped[citySnapshot.key ?: "Sin ciudad"] = days - } } - _uiState.update { it.copy(itineraryHistory = grouped) } + + val sortedGrouped = dayMap.mapValues { entry -> + entry.value.sortedByDescending { it.visitedAt } + } + + _uiState.update { it.copy(itineraryHistory = sortedGrouped) } } override fun onCancelled(error: DatabaseError) { diff --git a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt index bbe6378..1b5c70d 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/viewModel/placesViewModel.kt @@ -8,6 +8,12 @@ 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()) @@ -17,8 +23,13 @@ class PlacesViewModel : ViewModel() { 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() { @@ -92,19 +103,39 @@ class PlacesViewModel : ViewModel() { fun addToItinerary(place: Place) { val current = _uiState.value.itinerary if (current.none { it.id == place.id }) { - _uiState.update { it.copy(itinerary = current + place) } + 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 }) { - _uiState.update { it.copy(itinerary = current.filterNot { 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 { it.copy(selectedPlace = 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?) { @@ -151,6 +182,56 @@ class PlacesViewModel : ViewModel() { 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) { From c722d4190ac886150a70e8f31421a95dc9ea093f Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Wed, 3 Jun 2026 00:57:07 -0500 Subject: [PATCH 47/49] [Fix]: Rework ItineraryItemCard layout and standardize icons The `ItineraryItemCard` is refactored to place action buttons in a dedicated row, enhancing visual hierarchy and user interaction. Default Material Icons are replaced with custom vector drawables in several components (ItineraryItemCard, MapFloatingActions, ViewDropnote, ProfileTemplate) to improve UI consistency and branding. Expanded images in `ViewDropnote` now display with rounded corners for a polished look. --- .../molecules/itinerary/ItineraryItemCard.kt | 171 +++++++++--------- .../molecules/map/MapFloatingActions.kt | 3 +- .../components/organisms/map/ViewDropnote.kt | 7 +- .../rumbo/ui/templates/ProfileTemplate.kt | 14 +- app/src/main/res/drawable/ic_calendar.xml | 9 + app/src/main/res/drawable/ic_destroy.xml | 9 + app/src/main/res/drawable/ic_heat.xml | 9 + app/src/main/res/drawable/ic_logout.xml | 9 + app/src/main/res/drawable/ic_recuerdos.xml | 9 + 9 files changed, 147 insertions(+), 93 deletions(-) create mode 100644 app/src/main/res/drawable/ic_calendar.xml create mode 100644 app/src/main/res/drawable/ic_destroy.xml create mode 100644 app/src/main/res/drawable/ic_heat.xml create mode 100644 app/src/main/res/drawable/ic_logout.xml create mode 100644 app/src/main/res/drawable/ic_recuerdos.xml 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 d31663e..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 @@ -11,6 +11,7 @@ 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 @@ -101,104 +102,108 @@ fun ItineraryItemCard(p: Place, placesViewModel: PlacesViewModel, controller: Na ), shape = MaterialTheme.shapes.medium ) { - Row( + Column ( modifier = Modifier - .fillMaxWidth() - .padding(12.dp) + .padding(12.dp), ) { - Box( + Row( modifier = Modifier - .weight(4f) - .aspectRatio(1f) - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.secondaryContainer), - contentAlignment = Alignment.Center + .fillMaxWidth() ) { - 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) + Box( + modifier = Modifier + .weight(4f) + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center ) { - 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 + 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 + ) } - 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) ) - 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 ) - IconButton(onClick = { placesViewModel.removeFromItinerary(p) }) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Eliminar del Itinerario", - tint = MaterialTheme.colorScheme.error - ) - } } } } } - } /** 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 375a3d8..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 @@ -113,7 +113,8 @@ fun ToggleHeatmap(isHeatmapActive: Boolean = false, onClick: () -> Unit = {}) { ) { IconButton(onClick = onClick) { Icon( - painter = painterResource(R.drawable.ic_map), // Reusing map icon or any suitable icon + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_heat), contentDescription = "Toggle Heatmap", tint = iconTint, ) 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 44f9487..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 @@ -45,9 +45,12 @@ 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) @@ -133,7 +136,7 @@ fun ViewDropNote( modifier = Modifier.size(36.dp) ) { Icon( - imageVector = Icons.Rounded.Delete, + painter = painterResource(R.drawable.ic_destroy), contentDescription = "Eliminar DropNote", tint = MaterialTheme.colorScheme.error ) @@ -206,7 +209,7 @@ fun ViewDropNote( model = imageUrl, contentDescription = "Imagen ampliada", modifier = Modifier - .fillMaxWidth() + .fillMaxWidth().clip(RoundedCornerShape(12.dp)) .clickable { isImageExpanded = false }, contentScale = ContentScale.Fit ) 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 index 53d82b9..7a350b1 100644 --- a/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ProfileTemplate.kt +++ b/app/src/main/java/com/appnotresponding/rumbo/ui/templates/ProfileTemplate.kt @@ -134,10 +134,10 @@ fun ProfileTemplate( }, leadingIcon = { Icon( - imageVector = when (menu) { - ProfileMenu.EditData -> Icons.Rounded.Edit - ProfileMenu.ItineraryHistory -> Icons.Rounded.Event - ProfileMenu.Memories -> Icons.Rounded.Image + 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) ) }) @@ -195,10 +195,10 @@ private fun ProfileHeader( verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = onBackClick) { - Icon(Icons.Rounded.ArrowBack, contentDescription = "Volver") + Icon(painter = painterResource(R.drawable.ic_arrow_left), contentDescription = "Volver") } IconButton(onClick = onSignOut) { - Icon(Icons.Rounded.Logout, contentDescription = "Cerrar sesión") + Icon(painter = painterResource(R.drawable.ic_logout), contentDescription = "Cerrar sesión") } } @@ -226,7 +226,7 @@ private fun ProfileHeader( ) { Box(contentAlignment = Alignment.Center) { Icon( - imageVector = Icons.Rounded.PhotoCamera, + painter = painterResource(R.drawable.ic_camera), contentDescription = "Cambiar foto", modifier = Modifier.size(18.dp) ) 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_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_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 @@ + + + From 64dedec2f358a9c0af428a5b0f01ab7cce8714c9 Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Wed, 3 Jun 2026 01:30:02 -0500 Subject: [PATCH 48/49] [Fix]: Accesible Auth flow --- .../com/appnotresponding/rumbo/ui/templates/AuthTemplate.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 582bf0f..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,6 +4,7 @@ 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 @@ -35,7 +36,7 @@ fun AuthTemplate( Box( modifier = modifier - .fillMaxSize() + .fillMaxSize().imePadding() .background(MaterialTheme.colorScheme.background) .background(brush = backgroundBrush) .padding(24.dp), contentAlignment = Alignment.Center From 479f9e629b9e252911438712b87733b11f23eebc Mon Sep 17 00:00:00 2001 From: JDOG Date: Wed, 3 Jun 2026 01:36:34 -0500 Subject: [PATCH 49/49] [Fix]: Photos taken with camera sent through chat --- .../rumbo/ui/screens/chat/ChatThreadScreen.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 f4a0032..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 @@ -75,6 +75,21 @@ fun ChatThreadScreen( 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) }