From 0784ebd38d143aedccdfa1bab6a2ddbabdc76c07 Mon Sep 17 00:00:00 2001 From: Samuel Pico Date: Tue, 2 Jun 2026 10:15:38 -0500 Subject: [PATCH] [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) + } + } + } +}