From c41c15ad4a6a82f563da1fb84498d8e3dcde3ff6 Mon Sep 17 00:00:00 2001 From: equinox-1092 Date: Wed, 10 Jun 2026 18:05:47 -0500 Subject: [PATCH 01/15] feat(profiles): add profile dto models and remote mapper. --- .../core/navigation/graph/AppNavGraph.kt | 9 ++- .../intiva/core/navigation/graph/MainShell.kt | 62 ++++++++++++++++++- .../core/navigation/routes/NavRoutes.kt | 6 ++ .../finances/presentation/HomeScreen.kt | 23 +++++-- .../transactions/TransactionsScreen.kt | 29 ++++++--- .../data/remote/ProfileFacadeService.kt | 19 ++++++ .../data/remote/mappers/ProfileMapper.kt | 17 +++++ .../data/remote/models/ProfileResponseDto.kt | 14 +++++ .../data/remote/services/ProfileService.kt | 31 ++++++++++ .../repositories/ProfileRepositoryImpl.kt | 39 ++++++++++++ .../presentation/SavingsGoalsScreen.kt | 27 +++++--- 11 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/data/remote/ProfileFacadeService.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/data/remote/mappers/ProfileMapper.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/data/remote/models/ProfileResponseDto.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/data/remote/services/ProfileService.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/data/repositories/ProfileRepositoryImpl.kt diff --git a/app/src/main/java/com/resolum/intiva/core/navigation/graph/AppNavGraph.kt b/app/src/main/java/com/resolum/intiva/core/navigation/graph/AppNavGraph.kt index 8d0ee10..39ac6ea 100644 --- a/app/src/main/java/com/resolum/intiva/core/navigation/graph/AppNavGraph.kt +++ b/app/src/main/java/com/resolum/intiva/core/navigation/graph/AppNavGraph.kt @@ -97,7 +97,14 @@ fun AppNavGraph( } composable(Screen.MainShell.route) { - MainShell() + MainShell( + onLogout = { + navController.navigateAndClearBackStack( + route = Screen.SignIn.route, + popUpTo = Screen.MainShell.route, + ) + } + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/resolum/intiva/core/navigation/graph/MainShell.kt b/app/src/main/java/com/resolum/intiva/core/navigation/graph/MainShell.kt index 20cce51..e50ddc2 100644 --- a/app/src/main/java/com/resolum/intiva/core/navigation/graph/MainShell.kt +++ b/app/src/main/java/com/resolum/intiva/core/navigation/graph/MainShell.kt @@ -29,13 +29,23 @@ import com.resolum.intiva.features.savings.presentation.completion.GoalCompleted import com.resolum.intiva.features.savings.presentation.completion.GoalUncompletedScreen import com.resolum.intiva.features.savings.presentation.contribute.ContributeToGoalScreen import com.resolum.intiva.features.shared.domain.model.OwnerType +import com.resolum.intiva.features.profiles.presentation.ProfileScreen +import com.resolum.intiva.features.profiles.presentation.EditProfileScreen +import com.resolum.intiva.features.profiles.presentation.ConfiguracionScreen +import com.resolum.intiva.features.profiles.presentation.PrivacidadSeguridadScreen +import com.resolum.intiva.features.profiles.presentation.CentroAyudaScreen +import com.resolum.intiva.features.profiles.presentation.NotificacionesScreen +import com.resolum.intiva.features.profiles.presentation.AparienciaScreen + /** * Main shell of the app, containing the bottom navigation and root-level destinations. * Each feature's main screen should be registered here, with deeper navigation handled within the feature's own nav graph. */ @Composable -fun MainShell() { +fun MainShell( + onLogout: () -> Unit = {} +) { val shellNavController = rememberNavController() val navBackStackEntry by shellNavController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route @@ -81,7 +91,55 @@ fun MainShell() { } composable(NavRoutes.FAMILY) { } - composable(NavRoutes.PROFILE) { } + + composable(NavRoutes.PROFILE) { + ProfileScreen( + onNavigateToEdit = { shellNavController.navigate(NavRoutes.EDIT_PROFILE) }, + onNavigateToConfig = { shellNavController.navigate(NavRoutes.CONFIG) }, + onPrivacyClick = { shellNavController.navigate(NavRoutes.PRIVACY) }, + onHelpClick = { shellNavController.navigate(NavRoutes.HELP) }, + onLogoutClick = onLogout + ) + } + + composable(NavRoutes.EDIT_PROFILE) { + EditProfileScreen( + onNavigateBack = { shellNavController.popBackStack() } + ) + } + + composable(NavRoutes.CONFIG) { + ConfiguracionScreen( + onNavigateToPersonalDetails = { shellNavController.navigate(NavRoutes.EDIT_PROFILE) }, + onNavigateToNotifications = { shellNavController.navigate(NavRoutes.NOTIFICATIONS) }, + onNavigateToAppearance = { shellNavController.navigate(NavRoutes.APPEARANCE) }, + onNavigateBack = { shellNavController.popBackStack() } + ) + } + + composable(NavRoutes.PRIVACY) { + PrivacidadSeguridadScreen( + onNavigateBack = { shellNavController.popBackStack() } + ) + } + + composable(NavRoutes.HELP) { + CentroAyudaScreen( + onNavigateBack = { shellNavController.popBackStack() } + ) + } + + composable(NavRoutes.NOTIFICATIONS) { + NotificacionesScreen( + onNavigateBack = { shellNavController.popBackStack() } + ) + } + + composable(NavRoutes.APPEARANCE) { + AparienciaScreen( + onNavigateBack = { shellNavController.popBackStack() } + ) + } // Payment methods and categories composable(NavRoutes.MANAGE_CATEGORIES) { diff --git a/app/src/main/java/com/resolum/intiva/core/navigation/routes/NavRoutes.kt b/app/src/main/java/com/resolum/intiva/core/navigation/routes/NavRoutes.kt index 4f38aca..244dd2b 100644 --- a/app/src/main/java/com/resolum/intiva/core/navigation/routes/NavRoutes.kt +++ b/app/src/main/java/com/resolum/intiva/core/navigation/routes/NavRoutes.kt @@ -16,6 +16,12 @@ object NavRoutes { const val SAVINGS_GOAL_UNCOMPLETED = "savings_goal_uncompleted/{accountId}/{goalId}" const val FAMILY = "family" const val PROFILE = "profile" + const val EDIT_PROFILE = "edit_profile" + const val CONFIG = "config" + const val PRIVACY = "privacy" + const val HELP = "help" + const val NOTIFICATIONS = "notifications" + const val APPEARANCE = "appearance" const val NEW_INCOME = "transactions/new_income" const val NEW_EXPENSE = "transaction/new_expense" diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/HomeScreen.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/HomeScreen.kt index d801e23..64cf023 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/presentation/HomeScreen.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/HomeScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.automirrored.filled.ReceiptLong import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.NotificationsNone +import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -67,6 +68,9 @@ import com.resolum.intiva.features.finances.presentation.transactions.components import com.resolum.intiva.features.iam.domain.models.FirstTransactionTutorialStep import com.resolum.intiva.features.iam.presentation.onboarding.OnboardingViewModel import com.resolum.intiva.features.iam.presentation.onboarding.components.SpotlightOverlay +import com.resolum.intiva.features.profiles.presentation.ProfileViewModel +import coil3.compose.AsyncImage +import androidx.compose.ui.layout.ContentScale /** * HomeScreen.kt @@ -92,12 +96,14 @@ fun HomeScreen( navController: NavController, viewModel: TransactionViewModel = hiltViewModel(), spendingLimitViewModel: SpendingLimitViewModel = hiltViewModel(), + profileViewModel: ProfileViewModel = hiltViewModel(), onNavigateToTransactions: () -> Unit, onNavigateToSpendingLimitAlert: () -> Unit = {} ) { val snackBarHostState = remember { SnackbarHostState() } val uiState by viewModel.uiState.collectAsState() val spendingLimitUiState by spendingLimitViewModel.uiState.collectAsState() + val profileUiState by profileViewModel.uiState.collectAsState() val onboardingViewModel: OnboardingViewModel = hiltViewModel() val onboardingState by onboardingViewModel.state.collectAsState() var incomeButtonRect by remember { mutableStateOf(null) } @@ -108,6 +114,7 @@ fun HomeScreen( onboardingViewModel.loadStatus() viewModel.getTransactionsByOwnerId(onlyLatest = true) spendingLimitViewModel.loadMonthlySpendingLimit() + profileViewModel.loadProfile() val success = navController.currentBackStackEntry ?.savedStateHandle @@ -141,15 +148,19 @@ fun HomeScreen( TopAppBar( title = { Row(verticalAlignment = Alignment.CenterVertically) { - Box( + val profile = (profileUiState.profileState as? UiState.Success)?.data + val avatarUrl = profile?.avatarUrl?.ifEmpty { null } + AsyncImage( + model = avatarUrl ?: "https://res.cloudinary.com/dcppsmlzd/image/upload/v1781121388/avatar_default_kf0yvc.png", + contentDescription = "Avatar", modifier = Modifier .size(32.dp) - .clip(CircleShape) - .background(Color.LightGray) + .clip(CircleShape), + contentScale = ContentScale.Crop ) Spacer(modifier = Modifier.width(8.dp)) Text( - "Intiva", + text = profile?.name?.split(" ")?.firstOrNull() ?: "Intiva", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = IntivaColors.TextPrimary @@ -186,9 +197,11 @@ fun HomeScreen( ) { item { + val profile = (profileUiState.profileState as? UiState.Success)?.data + val firstName = profile?.name?.split(" ")?.firstOrNull() ?: "Usuario" Column { Text( - text = "Hola, Jennifer 馃憢", + text = "Hola, $firstName 馃憢", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = IntivaColors.TextPrimary diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionsScreen.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionsScreen.kt index 6a31de5..c99b43c 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionsScreen.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionsScreen.kt @@ -41,14 +41,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import coil3.compose.AsyncImage import com.resolum.intiva.core.common.state.UiState import com.resolum.intiva.core.ui.theme.IntivaColors import com.resolum.intiva.features.finances.domain.models.TransactionType import com.resolum.intiva.features.finances.presentation.transactions.components.EmptyTransactionsContent +import com.resolum.intiva.features.profiles.presentation.ProfileViewModel enum class FilterOption(val title: String, val type: TransactionType?) { ALL("Todos", null), @@ -66,9 +69,11 @@ enum class FilterOption(val title: String, val type: TransactionType?) { @OptIn(ExperimentalMaterial3Api::class) @Composable fun TransactionsScreen( - viewModel: TransactionViewModel = hiltViewModel() + viewModel: TransactionViewModel = hiltViewModel(), + profileViewModel: ProfileViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() + val profileUiState by profileViewModel.uiState.collectAsState() var selectedFilter by remember { mutableStateOf(FilterOption.ALL) } LaunchedEffect(selectedFilter) { @@ -77,23 +82,31 @@ fun TransactionsScreen( ) } + LaunchedEffect(Unit) { + profileViewModel.loadProfile() + } + Scaffold( containerColor = Color.White, topBar = { TopAppBar( title = { Row(verticalAlignment = Alignment.CenterVertically) { - Box( + val profile = (profileUiState.profileState as? UiState.Success)?.data + val avatarUrl = profile?.avatarUrl?.ifEmpty { null } + AsyncImage( + model = avatarUrl ?: "https://res.cloudinary.com/dcppsmlzd/image/upload/v1781121388/avatar_default_kf0yvc.png", + contentDescription = "Avatar", modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(Color.LightGray) + .size(32.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop ) - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Intiva", + text = profile?.name?.split(" ")?.firstOrNull() ?: "Intiva", fontWeight = FontWeight.Bold, - fontSize = 22.sp, + fontSize = 20.sp, color = IntivaColors.TextPrimary ) } diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/ProfileFacadeService.kt b/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/ProfileFacadeService.kt new file mode 100644 index 0000000..3e96fbb --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/ProfileFacadeService.kt @@ -0,0 +1,19 @@ +package com.resolum.intiva.features.profiles.data.remote + +import com.resolum.intiva.features.profiles.data.remote.models.UpdateProfileRequestDto +import com.resolum.intiva.features.profiles.data.remote.services.ProfileService +import okhttp3.MultipartBody +import javax.inject.Inject + +class ProfileFacadeService @Inject constructor( + private val profileService: ProfileService +) { + suspend fun getProfile(userId: Long) = + profileService.getProfile(userId) + + suspend fun updateProfile(userId: Long, request: UpdateProfileRequestDto) = + profileService.updateProfile(userId, request) + + suspend fun updateAvatar(userId: Long, file: MultipartBody.Part) = + profileService.updateAvatar(userId, file) +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/mappers/ProfileMapper.kt b/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/mappers/ProfileMapper.kt new file mode 100644 index 0000000..993e78d --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/mappers/ProfileMapper.kt @@ -0,0 +1,17 @@ +package com.resolum.intiva.features.profiles.data.remote.mappers + +import com.resolum.intiva.features.profiles.data.remote.models.ProfileResponseDto +import com.resolum.intiva.features.profiles.domain.models.Profile + +fun ProfileResponseDto.toDomain(): Profile { + return Profile( + id = this.id ?: 0L, + name = this.name.orEmpty(), + avatarUrl = this.avatarUrl.orEmpty(), + email = this.email.orEmpty(), + userId = this.userId ?: 0L, + phoneNumber = this.phoneNumber.orEmpty(), + bio = this.bio.orEmpty(), + age = this.age ?: 0 + ) +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/models/ProfileResponseDto.kt b/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/models/ProfileResponseDto.kt new file mode 100644 index 0000000..62a0fde --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/models/ProfileResponseDto.kt @@ -0,0 +1,14 @@ +package com.resolum.intiva.features.profiles.data.remote.models + +import com.google.gson.annotations.SerializedName + +data class ProfileResponseDto( + @SerializedName("id") val id: Long?, + @SerializedName("userId") val userId: Long?, + @SerializedName("name") val name: String?, + @SerializedName("age") val age: Int?, + @SerializedName("avatar_url") val avatarUrl: String?, + @SerializedName("phone_number") val phoneNumber: String?, + @SerializedName("bio") val bio: String?, + @SerializedName("email") val email: String? +) diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/services/ProfileService.kt b/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/services/ProfileService.kt new file mode 100644 index 0000000..9bf5cb1 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/services/ProfileService.kt @@ -0,0 +1,31 @@ +package com.resolum.intiva.features.profiles.data.remote.services + +import com.resolum.intiva.features.profiles.data.remote.models.ProfileResponseDto +import com.resolum.intiva.features.profiles.data.remote.models.UpdateProfileRequestDto +import okhttp3.MultipartBody +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path + +interface ProfileService { + + @GET("profiles/{userId}") + suspend fun getProfile(@Path("userId") userId: Long): ProfileResponseDto + + @PUT("profiles/{userId}") + suspend fun updateProfile( + @Path("userId") userId: Long, + @Body request: UpdateProfileRequestDto + ): ProfileResponseDto + + @Multipart + @PATCH("profiles/{userId}/avatar") + suspend fun updateAvatar( + @Path("userId") userId: Long, + @Part file: MultipartBody.Part + ): ProfileResponseDto +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/data/repositories/ProfileRepositoryImpl.kt b/app/src/main/java/com/resolum/intiva/features/profiles/data/repositories/ProfileRepositoryImpl.kt new file mode 100644 index 0000000..8d69e74 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/data/repositories/ProfileRepositoryImpl.kt @@ -0,0 +1,39 @@ +package com.resolum.intiva.features.profiles.data.repositories + +import com.resolum.intiva.core.data.repository.BaseRepository +import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.profiles.data.remote.ProfileFacadeService +import com.resolum.intiva.features.profiles.data.remote.mappers.toDomain +import com.resolum.intiva.features.profiles.data.remote.models.UpdateProfileRequestDto +import com.resolum.intiva.features.profiles.domain.models.Profile +import com.resolum.intiva.features.profiles.domain.repositories.ProfileRepository +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import javax.inject.Inject + +class ProfileRepositoryImpl @Inject constructor( + private val profileFacadeService: ProfileFacadeService +) : BaseRepository(), ProfileRepository { + + override suspend fun getProfile(userId: Long): NetworkResult = safeCall { + profileFacadeService.getProfile(userId).toDomain() + } + + override suspend fun updateProfile( + userId: Long, + name: String, + bio: String, + phoneNumber: String + ): NetworkResult = safeCall { + val request = UpdateProfileRequestDto(name = name, bio = bio, phoneNumber = phoneNumber) + profileFacadeService.updateProfile(userId, request).toDomain() + } + + override suspend fun updateAvatar(userId: Long, imageFile: File): NetworkResult = safeCall { + val requestFile = imageFile.asRequestBody("image/*".toMediaTypeOrNull()) + val body = MultipartBody.Part.createFormData("file", imageFile.name, requestFile) + profileFacadeService.updateAvatar(userId, body).toDomain() + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/savings/presentation/SavingsGoalsScreen.kt b/app/src/main/java/com/resolum/intiva/features/savings/presentation/SavingsGoalsScreen.kt index 31c7ddd..2dec20f 100644 --- a/app/src/main/java/com/resolum/intiva/features/savings/presentation/SavingsGoalsScreen.kt +++ b/app/src/main/java/com/resolum/intiva/features/savings/presentation/SavingsGoalsScreen.kt @@ -17,12 +17,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import coil3.compose.AsyncImage +import com.resolum.intiva.core.common.state.UiState import com.resolum.intiva.core.ui.theme.IntivaColors +import com.resolum.intiva.features.profiles.presentation.ProfileViewModel import com.resolum.intiva.features.savings.domain.models.SavingGoal /** @@ -35,15 +39,18 @@ fun SavingsGoalsScreen( onNavigateToCreate: (accountId: Long) -> Unit, onNavigateToDetail: (accountId: Long, goalId: Long) -> Unit, onNavigateToEdit: (accountId: Long, goalId: Long) -> Unit, - viewModel: SavingsGoalsViewModel = hiltViewModel() + viewModel: SavingsGoalsViewModel = hiltViewModel(), + profileViewModel: ProfileViewModel = hiltViewModel() ) { val screenState by viewModel.uiState.collectAsState() + val profileUiState by profileViewModel.uiState.collectAsState() val accountId = screenState.accountId var goalToDelete by remember { mutableStateOf(null) } LaunchedEffect(Unit) { viewModel.refresh() + profileViewModel.loadProfile() } Scaffold( @@ -51,17 +58,21 @@ fun SavingsGoalsScreen( TopAppBar( title = { Row(verticalAlignment = Alignment.CenterVertically) { - Box( + val profile = (profileUiState.profileState as? UiState.Success)?.data + val avatarUrl = profile?.avatarUrl?.ifEmpty { null } + AsyncImage( + model = avatarUrl ?: "https://res.cloudinary.com/dcppsmlzd/image/upload/v1781121388/avatar_default_kf0yvc.png", + contentDescription = "Avatar", modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(Color.LightGray) + .size(32.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop ) - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(8.dp)) Text( - "Intiva", + text = profile?.name?.split(" ")?.firstOrNull() ?: "Intiva", fontWeight = FontWeight.Bold, - fontSize = 22.sp, + fontSize = 20.sp, color = IntivaColors.TextPrimary ) } From 61ca5476bbc441a6ed1e7376adebc5400ae6804c Mon Sep 17 00:00:00 2001 From: equinox-1092 Date: Wed, 10 Jun 2026 18:06:22 -0500 Subject: [PATCH 02/15] feat(profiles): add profilerepositoryimpl and profilerepository interface. --- .../profiles/domain/repositories/ProfileRepository.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/domain/repositories/ProfileRepository.kt diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/domain/repositories/ProfileRepository.kt b/app/src/main/java/com/resolum/intiva/features/profiles/domain/repositories/ProfileRepository.kt new file mode 100644 index 0000000..d0c4277 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/domain/repositories/ProfileRepository.kt @@ -0,0 +1,11 @@ +package com.resolum.intiva.features.profiles.domain.repositories + +import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.profiles.domain.models.Profile +import java.io.File + +interface ProfileRepository { + suspend fun getProfile(userId: Long): NetworkResult + suspend fun updateProfile(userId: Long, name: String, bio: String, phoneNumber: String): NetworkResult + suspend fun updateAvatar(userId: Long, imageFile: File): NetworkResult +} From 6b887a36e07892bd8c5ae3c38965e3d765cd7176 Mon Sep 17 00:00:00 2001 From: equinox-1092 Date: Wed, 10 Jun 2026 18:06:45 -0500 Subject: [PATCH 03/15] feat(profiles): add profile use cases. --- .../data/remote/models/UpdateProfileRequestDto.kt | 10 ++++++++++ .../features/profiles/domain/models/Profile.kt | 12 ++++++++++++ .../domain/usecase/UpdateProfileAvatarUseCase.kt | 15 +++++++++++++++ .../domain/usecase/UpdateProfileUseCase.kt | 14 ++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/data/remote/models/UpdateProfileRequestDto.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/domain/models/Profile.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/domain/usecase/UpdateProfileAvatarUseCase.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/domain/usecase/UpdateProfileUseCase.kt diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/models/UpdateProfileRequestDto.kt b/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/models/UpdateProfileRequestDto.kt new file mode 100644 index 0000000..42d868a --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/data/remote/models/UpdateProfileRequestDto.kt @@ -0,0 +1,10 @@ +package com.resolum.intiva.features.profiles.data.remote.models + +import com.google.gson.annotations.SerializedName + +data class UpdateProfileRequestDto( + @SerializedName("name") val name: String, + @SerializedName("bio") val bio: String, + @SerializedName("phoneNumber") val phoneNumber: String, + @SerializedName("age") val age: Int? = null +) diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/domain/models/Profile.kt b/app/src/main/java/com/resolum/intiva/features/profiles/domain/models/Profile.kt new file mode 100644 index 0000000..fbfdae3 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/domain/models/Profile.kt @@ -0,0 +1,12 @@ +package com.resolum.intiva.features.profiles.domain.models + +data class Profile( + val id: Long, + val name: String, + val avatarUrl: String, + val email: String, + val userId: Long, + val phoneNumber: String, + val bio: String, + val age: Int +) diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/domain/usecase/UpdateProfileAvatarUseCase.kt b/app/src/main/java/com/resolum/intiva/features/profiles/domain/usecase/UpdateProfileAvatarUseCase.kt new file mode 100644 index 0000000..667bbfd --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/domain/usecase/UpdateProfileAvatarUseCase.kt @@ -0,0 +1,15 @@ +package com.resolum.intiva.features.profiles.domain.usecase + +import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.profiles.domain.models.Profile +import com.resolum.intiva.features.profiles.domain.repositories.ProfileRepository +import java.io.File +import javax.inject.Inject + +class UpdateProfileAvatarUseCase @Inject constructor( + private val repository: ProfileRepository +) { + suspend operator fun invoke(userId: Long, imageFile: File): NetworkResult { + return repository.updateAvatar(userId, imageFile) + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/domain/usecase/UpdateProfileUseCase.kt b/app/src/main/java/com/resolum/intiva/features/profiles/domain/usecase/UpdateProfileUseCase.kt new file mode 100644 index 0000000..1310751 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/domain/usecase/UpdateProfileUseCase.kt @@ -0,0 +1,14 @@ +package com.resolum.intiva.features.profiles.domain.usecase + +import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.profiles.domain.models.Profile +import com.resolum.intiva.features.profiles.domain.repositories.ProfileRepository +import javax.inject.Inject + +class UpdateProfileUseCase @Inject constructor( + private val repository: ProfileRepository +) { + suspend operator fun invoke(userId: Long, name: String, bio: String, phoneNumber: String): NetworkResult { + return repository.updateProfile(userId, name, bio, phoneNumber) + } +} From c1532b91b94522c4f76e9cc7487601150cb70af9 Mon Sep 17 00:00:00 2001 From: equinox-1092 Date: Wed, 10 Jun 2026 18:07:01 -0500 Subject: [PATCH 04/15] feat(profiles): add profilemodule with hilt bindings. --- .../features/profiles/di/ProfileModule.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/di/ProfileModule.kt diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/di/ProfileModule.kt b/app/src/main/java/com/resolum/intiva/features/profiles/di/ProfileModule.kt new file mode 100644 index 0000000..673b247 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/di/ProfileModule.kt @@ -0,0 +1,25 @@ +package com.resolum.intiva.features.profiles.di + +import com.resolum.intiva.features.profiles.data.remote.services.ProfileService +import com.resolum.intiva.features.profiles.data.repositories.ProfileRepositoryImpl +import com.resolum.intiva.features.profiles.domain.repositories.ProfileRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ProfileModule { + + @Provides + @Singleton + fun provideProfileService(retrofit: Retrofit): ProfileService = + retrofit.create(ProfileService::class.java) + + @Provides + @Singleton + fun provideProfileRepository(impl: ProfileRepositoryImpl): ProfileRepository = impl +} From 25e5e2732b7dcf061116dfae89b0e81232487627 Mon Sep 17 00:00:00 2001 From: equinox-1092 Date: Wed, 10 Jun 2026 18:07:24 -0500 Subject: [PATCH 05/15] feat(profiles): add profileviewmodel and profileuistate. --- .../usecase/GetProfileByUserIdUseCase.kt | 14 + .../presentation/ConfiguracionScreens.kt | 313 +++++++++++++++++ .../presentation/EditProfileScreen.kt | 325 ++++++++++++++++++ .../profiles/presentation/ProfileScreen.kt | 111 ++++++ .../profiles/presentation/ProfileUiState.kt | 10 + .../profiles/presentation/ProfileViewModel.kt | 128 +++++++ .../components/EditProfileSuccessContent.kt | 42 +++ .../components/ProfileAvatarCard.kt | 52 +++ .../components/ProfileInfoCard.kt | 41 +++ .../components/ProfileMenuCard.kt | 31 ++ .../components/ProfileMenuItemCard.kt | 44 +++ 11 files changed, 1111 insertions(+) create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/domain/usecase/GetProfileByUserIdUseCase.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/presentation/ConfiguracionScreens.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/presentation/EditProfileScreen.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileScreen.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileUiState.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileViewModel.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/EditProfileSuccessContent.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileAvatarCard.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileInfoCard.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileMenuCard.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileMenuItemCard.kt diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/domain/usecase/GetProfileByUserIdUseCase.kt b/app/src/main/java/com/resolum/intiva/features/profiles/domain/usecase/GetProfileByUserIdUseCase.kt new file mode 100644 index 0000000..6db6a5a --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/domain/usecase/GetProfileByUserIdUseCase.kt @@ -0,0 +1,14 @@ +package com.resolum.intiva.features.profiles.domain.usecase + +import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.profiles.domain.models.Profile +import com.resolum.intiva.features.profiles.domain.repositories.ProfileRepository +import javax.inject.Inject + +class GetProfileByUserIdUseCase @Inject constructor( + private val repository: ProfileRepository +) { + suspend operator fun invoke(userId: Long): NetworkResult { + return repository.getProfile(userId) + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ConfiguracionScreens.kt b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ConfiguracionScreens.kt new file mode 100644 index 0000000..11492a5 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ConfiguracionScreens.kt @@ -0,0 +1,313 @@ +package com.resolum.intiva.features.profiles.presentation + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material3.* +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.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfiguracionScreen( + onNavigateToPersonalDetails: () -> Unit, + onNavigateToNotifications: () -> Unit, + onNavigateToAppearance: () -> Unit, + onNavigateBack: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Configuraci贸n") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Regresar") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Text( + text = "Configuraci贸n", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 24.dp) + ) + + // Section: CUENTA + Text( + text = "CUENTA", + style = MaterialTheme.typography.titleSmall, + color = Color.Gray, + modifier = Modifier.padding(bottom = 8.dp) + ) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + SettingsItem( + icon = Icons.Default.Badge, + title = "Detalles Personales", + onClick = onNavigateToPersonalDetails + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Section: GENERAL + Text( + text = "GENERAL", + style = MaterialTheme.typography.titleSmall, + color = Color.Gray, + modifier = Modifier.padding(bottom = 8.dp) + ) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Column { + SettingsItem( + icon = Icons.Default.Notifications, + title = "Notificaciones", + onClick = onNavigateToNotifications + ) + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + SettingsItem( + icon = Icons.Default.Palette, + title = "Apariencia", + onClick = onNavigateToAppearance + ) + } + } + } + } +} + +@Composable +fun SettingsItem( + icon: ImageVector, + title: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + modifier = Modifier.size(40.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.size(20.dp) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivacidadSeguridadScreen( + onNavigateBack: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Privacidad y Seguridad") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Regresar") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Text( + text = "Privacidad y Seguridad", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Text( + text = "En Intiva nos tomamos muy en serio la seguridad de tu informaci贸n personal y financiera. Tus datos est谩n encriptados y protegidos mediante est谩ndares de seguridad de nivel bancario.", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Protecci贸n de cuenta", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(4.dp)) + Text("Tu sesi贸n expira autom谩ticamente para prevenir accesos no autorizados si tu dispositivo queda inactivo.", style = MaterialTheme.typography.bodyMedium) + } + } + + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Uso de Datos", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(4.dp)) + Text("No compartimos tus datos con terceros. Solo procesamos tu informaci贸n financiera para ofrecerte las m茅tricas y herramientas del panel de control.", style = MaterialTheme.typography.bodyMedium) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CentroAyudaScreen( + onNavigateBack: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Centro de Ayuda") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Regresar") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Text( + text = "Centro de Ayuda", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Text( + text = "驴Tienes preguntas sobre c贸mo usar Intiva? A continuaci贸n te mostramos algunas respuestas frecuentes.", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 24.dp) + ) + + FAQItem( + question = "驴C贸mo registro un nuevo gasto?", + answer = "Ve a la pantalla principal y presiona el bot贸n '+' o de gastos. Rellena los datos como categor铆a, cuenta financiera, monto y fecha, y pulsa guardar." + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + FAQItem( + question = "驴Qu茅 son los l铆mites de gasto?", + answer = "Son herramientas para ayudarte a presupuestar. Puedes definir el monto m谩ximo que quieres gastar en un periodo determinado." + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + FAQItem( + question = "驴C贸mo cambio mi foto de perfil?", + answer = "Ve a Configuraci贸n > Detalles Personales, toca el bot贸n de la c谩mara sobre tu avatar para seleccionar una imagen de tu dispositivo." + ) + } + } +} + +@Composable +fun FAQItem(question: String, answer: String) { + Column { + Text(text = question, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = answer, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificacionesScreen(onNavigateBack: () -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Notificaciones") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Regresar") + } + } + ) + } + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) { + Text("Pantalla de configuraci贸n de notificaciones (Pr贸ximamente)", style = MaterialTheme.typography.bodyLarge) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AparienciaScreen(onNavigateBack: () -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Apariencia") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Regresar") + } + } + ) + } + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) { + Text("Pantalla de configuraci贸n de apariencia y temas (Pr贸ximamente)", style = MaterialTheme.typography.bodyLarge) + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/EditProfileScreen.kt b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/EditProfileScreen.kt new file mode 100644 index 0000000..8d89b4f --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/EditProfileScreen.kt @@ -0,0 +1,325 @@ +package com.resolum.intiva.features.profiles.presentation + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil3.compose.AsyncImage +import com.resolum.intiva.core.common.state.UiState +import com.resolum.intiva.features.profiles.presentation.components.EditProfileSuccessContent +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditProfileScreen( + onNavigateBack: () -> Unit, + viewModel: ProfileViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + + // Load profile data when screen opens so fields are pre-populated + LaunchedEffect(Unit) { + viewModel.loadProfile() + } + + val launcher = rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri: Uri? -> + uri?.let { + val file = getFileFromUri(context, it) + if (file != null) { + viewModel.updateAvatar(file) + } + } + } + + Scaffold( + containerColor = Color.White, + topBar = { + TopAppBar( + title = { Text("Detalles Personales") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Regresar") + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.White) + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + when { + uiState.updateState is UiState.Success -> { + EditProfileSuccessContent(onContinueClick = { + viewModel.clearUpdateState() + onNavigateBack() + }) + } + else -> { + val profile = (uiState.profileState as? UiState.Success)?.data + + var name by remember(profile) { mutableStateOf(profile?.name.orEmpty()) } + var bio by remember(profile) { mutableStateOf(profile?.bio.orEmpty()) } + var phoneNumber by remember(profile) { mutableStateOf(profile?.phoneNumber.orEmpty()) } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Avatar Section with camera badge overlay + Box( + contentAlignment = Alignment.BottomEnd, + modifier = Modifier + .size(130.dp) + .clickable { launcher.launch("image/*") } + ) { + AsyncImage( + model = profile?.avatarUrl?.ifEmpty { "https://res.cloudinary.com/dcppsmlzd/image/upload/v1781121388/avatar_default_kf0yvc.png" } + ?: "https://res.cloudinary.com/dcppsmlzd/image/upload/v1781121388/avatar_default_kf0yvc.png", + contentDescription = "Avatar", + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + // Camera Icon Badge Overlay + Surface( + modifier = Modifier.size(36.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + tonalElevation = 4.dp + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.PhotoCamera, + contentDescription = "Change photo", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Cambiar foto", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable { launcher.launch("image/*") } + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // Card enclosing the form fields for premium look + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + // Full Name + Text( + text = "Nombre completo", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(6.dp)) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + leadingIcon = { + Icon(imageVector = Icons.Default.Person, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // Email Address (Read Only) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Correo electr贸nico", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.height(6.dp)) + OutlinedTextField( + value = profile?.email.orEmpty(), + onValueChange = {}, + leadingIcon = { + Icon(imageVector = Icons.Default.Email, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) + }, + trailingIcon = { + Icon(imageVector = Icons.Default.Lock, contentDescription = "Locked", tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + }, + modifier = Modifier.fillMaxWidth(), + readOnly = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + ) + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Para cambiar tu correo, contacta a soporte.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // Phone Number + Text( + text = "Tel茅fono", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(6.dp)) + OutlinedTextField( + value = phoneNumber, + onValueChange = { phoneNumber = it }, + leadingIcon = { + Icon(imageVector = Icons.Default.Phone, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + // Bio + Text( + text = "Bio", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(6.dp)) + OutlinedTextField( + value = bio, + onValueChange = { bio = it }, + leadingIcon = { + Icon(imageVector = Icons.Default.Info, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = { viewModel.updateProfile(name, bio, phoneNumber) }, + modifier = Modifier.fillMaxWidth(), + enabled = uiState.updateState !is UiState.Loading + ) { + if (uiState.updateState is UiState.Loading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + Text("GUARDAR CAMBIOS") + } + } + + if (uiState.updateState is UiState.Error) { + Text( + text = (uiState.updateState as UiState.Error).message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp) + ) + } + + if (uiState.avatarUpdateState is UiState.Error) { + Text( + text = (uiState.avatarUpdateState as UiState.Error).message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } + } + } + } +} + +private fun getFileFromUri(context: Context, uri: Uri): File? { + return try { + val inputStream: InputStream? = context.contentResolver.openInputStream(uri) + val tempFile = File.createTempFile("avatar_upload", ".jpg", context.cacheDir) + val outputStream = FileOutputStream(tempFile) + inputStream?.copyTo(outputStream) + inputStream?.close() + outputStream.close() + tempFile + } catch (e: Exception) { + null + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileScreen.kt b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileScreen.kt new file mode 100644 index 0000000..1cf6f84 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileScreen.kt @@ -0,0 +1,111 @@ +package com.resolum.intiva.features.profiles.presentation + +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.resolum.intiva.core.common.state.UiState +import com.resolum.intiva.features.profiles.presentation.components.ProfileAvatarCard +import com.resolum.intiva.features.profiles.presentation.components.ProfileInfoCard +import com.resolum.intiva.features.profiles.presentation.components.ProfileMenuCard + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen( + onNavigateToEdit: () -> Unit, + onNavigateToConfig: () -> Unit, + onPrivacyClick: () -> Unit, + onHelpClick: () -> Unit, + onLogoutClick: () -> Unit, + viewModel: ProfileViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadProfile() + } + + Scaffold( + containerColor = Color.White, + topBar = { + TopAppBar( + title = { Text("Intiva") }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.White) + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + when (val state = uiState.profileState) { + is UiState.Loading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + is UiState.Success -> { + val profile = state.data + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + ProfileAvatarCard( + avatarUrl = profile.avatarUrl, + name = profile.name, + email = profile.email + ) + Spacer(modifier = Modifier.height(24.dp)) + ProfileInfoCard(memberId = "#INT-${profile.id.toString().padStart(4, '0').takeLast(4).uppercase()}") + Spacer(modifier = Modifier.height(24.dp)) + ProfileMenuCard( + onConfigClick = onNavigateToConfig, + onPrivacyClick = onPrivacyClick, + onHelpClick = onHelpClick + ) + Spacer(modifier = Modifier.height(32.dp)) + OutlinedButton( + onClick = { viewModel.logout(onLogoutClick) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red) + ) { + Text(text = "Cerrar sesi贸n") + } + } + } + is UiState.Error -> { + Text( + text = state.message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.align(Alignment.Center) + ) + } + else -> {} + } + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileUiState.kt b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileUiState.kt new file mode 100644 index 0000000..be5daeb --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileUiState.kt @@ -0,0 +1,10 @@ +package com.resolum.intiva.features.profiles.presentation + +import com.resolum.intiva.core.common.state.UiState +import com.resolum.intiva.features.profiles.domain.models.Profile + +data class ProfileUiState( + val profileState: UiState = UiState.Idle, + val updateState: UiState = UiState.Idle, + val avatarUpdateState: UiState = UiState.Idle +) diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileViewModel.kt b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileViewModel.kt new file mode 100644 index 0000000..97c8082 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileViewModel.kt @@ -0,0 +1,128 @@ +package com.resolum.intiva.features.profiles.presentation + +import com.resolum.intiva.core.common.state.UiState +import com.resolum.intiva.core.common.viewmodel.BaseViewModel +import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.iam.domain.repositories.SessionRepository +import com.resolum.intiva.features.profiles.domain.usecase.GetProfileByUserIdUseCase +import com.resolum.intiva.features.profiles.domain.usecase.UpdateProfileAvatarUseCase +import com.resolum.intiva.features.profiles.domain.usecase.UpdateProfileUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val getProfileByUserIdUseCase: GetProfileByUserIdUseCase, + private val updateProfileUseCase: UpdateProfileUseCase, + private val updateProfileAvatarUseCase: UpdateProfileAvatarUseCase, + private val sessionRepository: SessionRepository +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(ProfileUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadProfile() { + safeLaunch { + _uiState.update { it.copy(profileState = UiState.Loading) } + + val userId = sessionRepository.getUserId() + if (userId == null) { + _uiState.update { + it.copy(profileState = UiState.Error(message = "No se pudo obtener el usuario de la sesi贸n.")) + } + return@safeLaunch + } + + when (val result = getProfileByUserIdUseCase(userId)) { + is NetworkResult.Success -> { + _uiState.update { it.copy(profileState = UiState.Success(result.data)) } + } + is NetworkResult.Error -> { + _uiState.update { + it.copy(profileState = UiState.Error(message = result.message, throwable = result.throwable)) + } + } + } + } + } + + fun updateProfile(name: String, bio: String, phoneNumber: String) { + safeLaunch { + _uiState.update { it.copy(updateState = UiState.Loading) } + + val userId = sessionRepository.getUserId() + if (userId == null) { + _uiState.update { + it.copy(updateState = UiState.Error(message = "No se pudo obtener el usuario de la sesi贸n.")) + } + return@safeLaunch + } + + when (val result = updateProfileUseCase(userId, name, bio, phoneNumber)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy(updateState = UiState.Success(result.data), profileState = UiState.Success(result.data)) + } + } + is NetworkResult.Error -> { + _uiState.update { + it.copy(updateState = UiState.Error(message = result.message, throwable = result.throwable)) + } + } + } + } + } + + fun updateAvatar(imageFile: File) { + safeLaunch { + _uiState.update { it.copy(avatarUpdateState = UiState.Loading) } + + val userId = sessionRepository.getUserId() + if (userId == null) { + _uiState.update { + it.copy(avatarUpdateState = UiState.Error(message = "No se pudo obtener el usuario de la sesi贸n.")) + } + return@safeLaunch + } + + when (val result = updateProfileAvatarUseCase(userId, imageFile)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy(avatarUpdateState = UiState.Success(result.data), profileState = UiState.Success(result.data)) + } + } + is NetworkResult.Error -> { + _uiState.update { + it.copy(avatarUpdateState = UiState.Error(message = result.message, throwable = result.throwable)) + } + } + } + } + } + + fun clearUpdateState() { + _uiState.update { it.copy(updateState = UiState.Idle) } + } + + fun clearAvatarUpdateState() { + _uiState.update { it.copy(avatarUpdateState = UiState.Idle) } + } + + fun logout(onSuccess: () -> Unit) { + safeLaunch { + sessionRepository.clearToken() + onSuccess() + } + } + + override fun handleError(throwable: Throwable) { + _uiState.update { + it.copy(profileState = UiState.Error(message = throwable.message ?: "Error desconocido", throwable = throwable)) + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/EditProfileSuccessContent.kt b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/EditProfileSuccessContent.kt new file mode 100644 index 0000000..d983589 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/EditProfileSuccessContent.kt @@ -0,0 +1,42 @@ +package com.resolum.intiva.features.profiles.presentation.components + +import androidx.compose.foundation.layout.Column +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.Button +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.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun EditProfileSuccessContent( + onContinueClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Perfil actualizado exitosamente", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = onContinueClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Continuar") + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileAvatarCard.kt b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileAvatarCard.kt new file mode 100644 index 0000000..dfdfe99 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileAvatarCard.kt @@ -0,0 +1,52 @@ +package com.resolum.intiva.features.profiles.presentation.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage + +@Composable +fun ProfileAvatarCard( + avatarUrl: String, + name: String, + email: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AsyncImage( + model = avatarUrl.ifEmpty { "https://res.cloudinary.com/dcppsmlzd/image/upload/v1781121388/avatar_default_kf0yvc.png" }, + contentDescription = "User Avatar", + modifier = Modifier + .size(100.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = email, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileInfoCard.kt b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileInfoCard.kt new file mode 100644 index 0000000..65a5738 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileInfoCard.kt @@ -0,0 +1,41 @@ +package com.resolum.intiva.features.profiles.presentation.components + +import androidx.compose.foundation.layout.Column +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.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun ProfileInfoCard( + memberId: String, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Miembro ID", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = memberId, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileMenuCard.kt b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileMenuCard.kt new file mode 100644 index 0000000..5809e2b --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileMenuCard.kt @@ -0,0 +1,31 @@ +package com.resolum.intiva.features.profiles.presentation.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun ProfileMenuCard( + onConfigClick: () -> Unit, + onPrivacyClick: () -> Unit, + onHelpClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column { + ProfileMenuItemCard(title = "Configuraci贸n", onClick = onConfigClick) + ProfileMenuItemCard(title = "Privacidad y Seguridad", onClick = onPrivacyClick) + ProfileMenuItemCard(title = "Centro de Ayuda", onClick = onHelpClick) + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileMenuItemCard.kt b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileMenuItemCard.kt new file mode 100644 index 0000000..181a8df --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/components/ProfileMenuItemCard.kt @@ -0,0 +1,44 @@ +package com.resolum.intiva.features.profiles.presentation.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ProfileMenuItemCard( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 16.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(16.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Navigate to $title", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } +} From 02548b5bf05172f2098b11d614af06719b4f0188 Mon Sep 17 00:00:00 2001 From: Giovany7x Date: Thu, 11 Jun 2026 22:35:18 -0500 Subject: [PATCH 06/15] feat(profile): improve settings screen UI --- .../presentation/ConfiguracionScreens.kt | 538 +++++++++++++----- 1 file changed, 398 insertions(+), 140 deletions(-) diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ConfiguracionScreens.kt b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ConfiguracionScreens.kt index 11492a5..46c26e8 100644 --- a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ConfiguracionScreens.kt +++ b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ConfiguracionScreens.kt @@ -1,109 +1,234 @@ package com.resolum.intiva.features.profiles.presentation +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width 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.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.Badge -import androidx.compose.material3.* +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults 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.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val IntivaPrimary = Color(0xFF534AB7) +private val IntivaBackground = Color(0xFFFAF7FF) +private val IntivaIconBackground = Color(0xFFF1EEFF) +private val IntivaTextPrimary = Color(0xFF1F1B2D) +private val IntivaTextSecondary = Color(0xFF78767E) +private val IntivaDivider = Color(0xFFE9E6F2) +private val IntivaDanger = Color(0xFFE53935) @OptIn(ExperimentalMaterial3Api::class) @Composable + fun ConfiguracionScreen( onNavigateToPersonalDetails: () -> Unit, onNavigateToNotifications: () -> Unit, onNavigateToAppearance: () -> Unit, - onNavigateBack: () -> Unit + onNavigateBack: () -> Unit, + onNavigateToCategories: () -> Unit = {}, + onNavigateToLinkedAccounts: () -> Unit = {}, + onNavigateToAgreements: () -> Unit = {} ) { Scaffold( + containerColor = IntivaBackground, topBar = { - TopAppBar( - title = { Text("Configuraci贸n") }, + CenterAlignedTopAppBar( + modifier = Modifier.statusBarsPadding(), + title = { + Text( + text = "INTIVA", + color = IntivaTextPrimary, + fontSize = 21.sp, + fontWeight = FontWeight.ExtraBold + ) + }, navigationIcon = { IconButton(onClick = onNavigateBack) { - Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Regresar") + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Regresar", + tint = IntivaPrimary + ) } - } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.White + ) ) } ) { paddingValues -> Column( modifier = Modifier .fillMaxSize() + .background(IntivaBackground) .padding(paddingValues) .verticalScroll(rememberScrollState()) - .padding(16.dp) + .navigationBarsPadding() + .padding(horizontal = 24.dp) ) { + Spacer(modifier = Modifier.height(22.dp)) + Text( text = "Configuraci贸n", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 24.dp) + color = IntivaTextPrimary, + fontSize = 30.sp, + fontWeight = FontWeight.ExtraBold ) - // Section: CUENTA - Text( - text = "CUENTA", - style = MaterialTheme.typography.titleSmall, - color = Color.Gray, - modifier = Modifier.padding(bottom = 8.dp) - ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - ) { + Spacer(modifier = Modifier.height(24.dp)) + + SectionTitle(text = "CUENTA") + + SettingsCard { SettingsItem( icon = Icons.Default.Badge, - title = "Detalles Personales", + title = "Detalles personales", onClick = onNavigateToPersonalDetails ) + + SettingsDivider() + + SettingsItem( + icon = Icons.Default.Link, + title = "Cuentas vinculadas", + onClick = onNavigateToLinkedAccounts + ) + + SettingsDivider() + + SettingsItem( + icon = Icons.Default.Link, + title = "Gestionar categor铆as", + onClick = onNavigateToCategories + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + SectionTitle(text = "GENERAL") + + SettingsCard { + SettingsItem( + icon = Icons.Default.Notifications, + title = "Notificaciones", + onClick = onNavigateToNotifications + ) + + SettingsDivider() + + SettingsItem( + icon = Icons.Default.Palette, + title = "Apariencia", + onClick = onNavigateToAppearance + ) + + SettingsDivider() + + SettingsItem( + icon = Icons.Default.Badge, + title = "Acuerdos", + onClick = onNavigateToAgreements + ) } + Spacer(modifier = Modifier.height(28.dp)) + + + Spacer(modifier = Modifier.height(24.dp)) - // Section: GENERAL Text( - text = "GENERAL", - style = MaterialTheme.typography.titleSmall, - color = Color.Gray, - modifier = Modifier.padding(bottom = 8.dp) + text = "Versi贸n 1.1.0", + color = IntivaTextSecondary.copy(alpha = 0.45f), + fontSize = 13.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() ) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - ) { - Column { - SettingsItem( - icon = Icons.Default.Notifications, - title = "Notificaciones", - onClick = onNavigateToNotifications - ) - HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) - SettingsItem( - icon = Icons.Default.Palette, - title = "Apariencia", - onClick = onNavigateToAppearance - ) - } - } + + Spacer(modifier = Modifier.height(32.dp)) } } } @Composable -fun SettingsItem( +private fun SectionTitle(text: String) { + Text( + text = text, + color = IntivaTextSecondary, + fontSize = 12.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 1.sp, + modifier = Modifier.padding(start = 8.dp, bottom = 10.dp) + ) +} + +@Composable +private fun SettingsCard( + content: @Composable ColumnScope.() -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 5.dp + ) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + content = content + ) + } +} + +@Composable +private fun SettingsItem( icon: ImageVector, title: String, onClick: () -> Unit @@ -111,53 +236,82 @@ fun SettingsItem( Row( modifier = Modifier .fillMaxWidth() + .height(66.dp) .clickable(onClick = onClick) - .padding(16.dp), + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), - modifier = Modifier.size(40.dp) + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(IntivaIconBackground), + contentAlignment = Alignment.Center ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - } + Icon( + imageVector = icon, + contentDescription = null, + tint = IntivaPrimary, + modifier = Modifier.size(21.dp) + ) } + Spacer(modifier = Modifier.width(16.dp)) + Text( text = title, - style = MaterialTheme.typography.bodyLarge, + color = IntivaTextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f) ) + Icon( imageVector = Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, - tint = Color.Gray, + tint = Color(0xFFC5C1D3), modifier = Modifier.size(20.dp) ) } } +@Composable +private fun SettingsDivider() { + HorizontalDivider( + modifier = Modifier.padding(start = 70.dp), + color = IntivaDivider, + thickness = 1.dp + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacidadSeguridadScreen( onNavigateBack: () -> Unit ) { Scaffold( + containerColor = IntivaBackground, topBar = { - TopAppBar( - title = { Text("Privacidad y Seguridad") }, + CenterAlignedTopAppBar( + title = { + Text( + text = "Privacidad y seguridad", + fontWeight = FontWeight.Bold, + color = IntivaTextPrimary + ) + }, navigationIcon = { IconButton(onClick = onNavigateBack) { - Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Regresar") + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Regresar", + tint = IntivaPrimary + ) } - } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.White + ) ) } ) { paddingValues -> @@ -166,42 +320,36 @@ fun PrivacidadSeguridadScreen( .fillMaxSize() .padding(paddingValues) .verticalScroll(rememberScrollState()) - .padding(16.dp) + .padding(24.dp) ) { Text( - text = "Privacidad y Seguridad", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) + text = "Privacidad y seguridad", + color = IntivaTextPrimary, + fontSize = 28.sp, + fontWeight = FontWeight.ExtraBold ) - + + Spacer(modifier = Modifier.height(16.dp)) + Text( - text = "En Intiva nos tomamos muy en serio la seguridad de tu informaci贸n personal y financiera. Tus datos est谩n encriptados y protegidos mediante est谩ndares de seguridad de nivel bancario.", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(bottom = 16.dp) + text = "En Intiva protegemos tu informaci贸n personal y financiera usando buenas pr谩cticas de seguridad y control de acceso.", + color = IntivaTextSecondary, + style = MaterialTheme.typography.bodyLarge ) - Card( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text("Protecci贸n de cuenta", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(4.dp)) - Text("Tu sesi贸n expira autom谩ticamente para prevenir accesos no autorizados si tu dispositivo queda inactivo.", style = MaterialTheme.typography.bodyMedium) - } - } + Spacer(modifier = Modifier.height(20.dp)) - Card( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text("Uso de Datos", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(4.dp)) - Text("No compartimos tus datos con terceros. Solo procesamos tu informaci贸n financiera para ofrecerte las m茅tricas y herramientas del panel de control.", style = MaterialTheme.typography.bodyMedium) - } - } + InfoCard( + title = "Protecci贸n de cuenta", + description = "Tu sesi贸n ayuda a prevenir accesos no autorizados y protege la informaci贸n vinculada a tu perfil." + ) + + Spacer(modifier = Modifier.height(12.dp)) + + InfoCard( + title = "Uso de datos", + description = "Procesamos tu informaci贸n para mostrar m茅tricas, categor铆as, transacciones y herramientas de control financiero." + ) } } } @@ -212,14 +360,28 @@ fun CentroAyudaScreen( onNavigateBack: () -> Unit ) { Scaffold( + containerColor = IntivaBackground, topBar = { - TopAppBar( - title = { Text("Centro de Ayuda") }, + CenterAlignedTopAppBar( + title = { + Text( + text = "Centro de ayuda", + fontWeight = FontWeight.Bold, + color = IntivaTextPrimary + ) + }, navigationIcon = { IconButton(onClick = onNavigateBack) { - Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Regresar") + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Regresar", + tint = IntivaPrimary + ) } - } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.White + ) ) } ) { paddingValues -> @@ -228,86 +390,182 @@ fun CentroAyudaScreen( .fillMaxSize() .padding(paddingValues) .verticalScroll(rememberScrollState()) - .padding(16.dp) + .padding(24.dp) ) { Text( - text = "Centro de Ayuda", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) + text = "Centro de ayuda", + color = IntivaTextPrimary, + fontSize = 28.sp, + fontWeight = FontWeight.ExtraBold ) + Spacer(modifier = Modifier.height(12.dp)) + Text( - text = "驴Tienes preguntas sobre c贸mo usar Intiva? A continuaci贸n te mostramos algunas respuestas frecuentes.", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(bottom = 24.dp) + text = "Encuentra respuestas r谩pidas para usar las funciones principales de Intiva.", + color = IntivaTextSecondary, + style = MaterialTheme.typography.bodyLarge ) + Spacer(modifier = Modifier.height(24.dp)) + FAQItem( question = "驴C贸mo registro un nuevo gasto?", - answer = "Ve a la pantalla principal y presiona el bot贸n '+' o de gastos. Rellena los datos como categor铆a, cuenta financiera, monto y fecha, y pulsa guardar." + answer = "Ve a la pantalla principal, selecciona la opci贸n de gasto, completa los datos y guarda la operaci贸n." ) - HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + color = IntivaDivider + ) + FAQItem( question = "驴Qu茅 son los l铆mites de gasto?", - answer = "Son herramientas para ayudarte a presupuestar. Puedes definir el monto m谩ximo que quieres gastar en un periodo determinado." + answer = "Son herramientas para ayudarte a controlar cu谩nto puedes gastar en un periodo determinado." ) - HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) + + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + color = IntivaDivider + ) + FAQItem( question = "驴C贸mo cambio mi foto de perfil?", - answer = "Ve a Configuraci贸n > Detalles Personales, toca el bot贸n de la c谩mara sobre tu avatar para seleccionar una imagen de tu dispositivo." + answer = "Ve a Configuraci贸n > Detalles personales y selecciona la opci贸n para actualizar tu avatar." ) } } } @Composable -fun FAQItem(question: String, answer: String) { +fun FAQItem( + question: String, + answer: String +) { Column { - Text(text = question, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = answer, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = question, + color = IntivaTextPrimary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = answer, + color = IntivaTextSecondary, + style = MaterialTheme.typography.bodyMedium + ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable -fun NotificacionesScreen(onNavigateBack: () -> Unit) { +fun NotificacionesScreen( + onNavigateBack: () -> Unit +) { + PlaceholderScreen( + title = "Notificaciones", + message = "Pantalla de configuraci贸n de notificaciones pr贸ximamente.", + onNavigateBack = onNavigateBack + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AparienciaScreen( + onNavigateBack: () -> Unit +) { + PlaceholderScreen( + title = "Apariencia", + message = "Pantalla de configuraci贸n de apariencia y temas pr贸ximamente.", + onNavigateBack = onNavigateBack + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PlaceholderScreen( + title: String, + message: String, + onNavigateBack: () -> Unit +) { Scaffold( + containerColor = IntivaBackground, topBar = { - TopAppBar( - title = { Text("Notificaciones") }, + CenterAlignedTopAppBar( + title = { + Text( + text = title, + fontWeight = FontWeight.Bold, + color = IntivaTextPrimary + ) + }, navigationIcon = { IconButton(onClick = onNavigateBack) { - Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Regresar") + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Regresar", + tint = IntivaPrimary + ) } - } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.White + ) ) } ) { paddingValues -> - Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) { - Text("Pantalla de configuraci贸n de notificaciones (Pr贸ximamente)", style = MaterialTheme.typography.bodyLarge) + Box( + modifier = Modifier + .fillMaxSize() + .background(IntivaBackground) + .padding(paddingValues) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = message, + color = IntivaTextSecondary, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge + ) } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun AparienciaScreen(onNavigateBack: () -> Unit) { - Scaffold( - topBar = { - TopAppBar( - title = { Text("Apariencia") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Regresar") - } - } +private fun InfoCard( + title: String, + description: String +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp + ) + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = title, + color = IntivaTextPrimary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = description, + color = IntivaTextSecondary, + style = MaterialTheme.typography.bodyMedium ) - } - ) { paddingValues -> - Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) { - Text("Pantalla de configuraci贸n de apariencia y temas (Pr贸ximamente)", style = MaterialTheme.typography.bodyLarge) } } -} +} \ No newline at end of file From e8f1d65147504ccd59d4b2675ca711927d029e5a Mon Sep 17 00:00:00 2001 From: Giovany7x Date: Thu, 11 Jun 2026 22:35:37 -0500 Subject: [PATCH 07/15] feat(navigation): connect settings to categories --- .../intiva/core/navigation/graph/MainShell.kt | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/resolum/intiva/core/navigation/graph/MainShell.kt b/app/src/main/java/com/resolum/intiva/core/navigation/graph/MainShell.kt index e50ddc2..5bc1b7f 100644 --- a/app/src/main/java/com/resolum/intiva/core/navigation/graph/MainShell.kt +++ b/app/src/main/java/com/resolum/intiva/core/navigation/graph/MainShell.kt @@ -110,10 +110,21 @@ fun MainShell( composable(NavRoutes.CONFIG) { ConfiguracionScreen( - onNavigateToPersonalDetails = { shellNavController.navigate(NavRoutes.EDIT_PROFILE) }, - onNavigateToNotifications = { shellNavController.navigate(NavRoutes.NOTIFICATIONS) }, - onNavigateToAppearance = { shellNavController.navigate(NavRoutes.APPEARANCE) }, - onNavigateBack = { shellNavController.popBackStack() } + onNavigateToPersonalDetails = { + shellNavController.navigate(NavRoutes.EDIT_PROFILE) + }, + onNavigateToCategories = { + shellNavController.navigate(NavRoutes.MANAGE_CATEGORIES) + }, + onNavigateToNotifications = { + shellNavController.navigate(NavRoutes.NOTIFICATIONS) + }, + onNavigateToAppearance = { + shellNavController.navigate(NavRoutes.APPEARANCE) + }, + onNavigateBack = { + shellNavController.popBackStack() + } ) } From bd102f3a28938383cc7695003a2525c9cef5db21 Mon Sep 17 00:00:00 2001 From: Giovany7x Date: Thu, 11 Jun 2026 22:35:54 -0500 Subject: [PATCH 08/15] feat(profile): improve profile screen UI --- .../profiles/presentation/ProfileScreen.kt | 203 +++++++++++++++--- 1 file changed, 173 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileScreen.kt b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileScreen.kt index 1cf6f84..ed1c939 100644 --- a/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileScreen.kt +++ b/app/src/main/java/com/resolum/intiva/features/profiles/presentation/ProfileScreen.kt @@ -1,22 +1,32 @@ package com.resolum.intiva.features.profiles.presentation +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background 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.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -25,13 +35,23 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.resolum.intiva.core.common.state.UiState import com.resolum.intiva.features.profiles.presentation.components.ProfileAvatarCard import com.resolum.intiva.features.profiles.presentation.components.ProfileInfoCard import com.resolum.intiva.features.profiles.presentation.components.ProfileMenuCard +private val IntivaPrimary = Color(0xFF534AB7) +private val IntivaSecondary = Color(0xFFCDEB45) +private val IntivaBackground = Color(0xFFFAF7FF) +private val IntivaTextPrimary = Color(0xFF1F1B2D) +private val IntivaTextSecondary = Color(0xFF78767E) +private val IntivaDanger = Color(0xFFE53935) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ProfileScreen( @@ -49,63 +69,186 @@ fun ProfileScreen( } Scaffold( - containerColor = Color.White, + containerColor = IntivaBackground, topBar = { - TopAppBar( - title = { Text("Intiva") }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.White) + CenterAlignedTopAppBar( + modifier = Modifier.statusBarsPadding(), + title = { + Text( + text = "INTIVA", + color = IntivaTextPrimary, + fontSize = 22.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 0.5.sp + ) + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.White, + titleContentColor = IntivaTextPrimary + ) ) } ) { paddingValues -> Box( modifier = Modifier .fillMaxSize() + .background(IntivaBackground) .padding(paddingValues) ) { when (val state = uiState.profileState) { is UiState.Loading -> { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = IntivaPrimary + ) } + is UiState.Success -> { val profile = state.data + val memberId = "#INT-${profile.id.toString().padStart(4, '0').takeLast(4).uppercase()}" + Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(16.dp) + .navigationBarsPadding() + .padding(horizontal = 24.dp) ) { - ProfileAvatarCard( - avatarUrl = profile.avatarUrl, - name = profile.name, - email = profile.email - ) - Spacer(modifier = Modifier.height(24.dp)) - ProfileInfoCard(memberId = "#INT-${profile.id.toString().padStart(4, '0').takeLast(4).uppercase()}") - Spacer(modifier = Modifier.height(24.dp)) - ProfileMenuCard( - onConfigClick = onNavigateToConfig, - onPrivacyClick = onPrivacyClick, - onHelpClick = onHelpClick + Spacer(modifier = Modifier.height(22.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 6.dp + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 18.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ProfileAvatarCard( + avatarUrl = profile.avatarUrl, + name = profile.name, + email = profile.email + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + ProfileInfoCard(memberId = memberId) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "CUENTA", + color = IntivaTextSecondary, + fontSize = 12.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 1.sp, + modifier = Modifier.padding(start = 8.dp, bottom = 10.dp) ) - Spacer(modifier = Modifier.height(32.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 5.dp + ) + ) { + ProfileMenuCard( + onConfigClick = onNavigateToConfig, + onPrivacyClick = onPrivacyClick, + onHelpClick = onHelpClick + ) + } + + Spacer(modifier = Modifier.height(28.dp)) + OutlinedButton( onClick = { viewModel.logout(onLogoutClick) }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red) + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(16.dp), + border = BorderStroke( + width = 1.dp, + color = IntivaDanger.copy(alpha = 0.85f) + ), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = IntivaDanger + ) ) { - Text(text = "Cerrar sesi贸n") + Icon( + imageVector = Icons.AutoMirrored.Filled.Logout, + contentDescription = null + ) + + Spacer(modifier = Modifier.padding(horizontal = 4.dp)) + + Text( + text = "CERRAR SESI脫N", + fontSize = 12.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 0.7.sp + ) } + + Spacer(modifier = Modifier.height(36.dp)) } } + is UiState.Error -> { - Text( - text = state.message, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.align(Alignment.Center) - ) + Card( + modifier = Modifier + .align(Alignment.Center) + .padding(24.dp), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 4.dp + ) + ) { + Text( + text = state.message, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.padding(24.dp) + ) + } } - else -> {} + + else -> Unit } } } -} +} \ No newline at end of file From e4760cc4eef903e75fd377770f40668a6aaab631 Mon Sep 17 00:00:00 2001 From: Giovany7x Date: Thu, 11 Jun 2026 22:39:39 -0500 Subject: [PATCH 09/15] fix(category): update service endpoints --- .../data/remote/services/CategoryService.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/services/CategoryService.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/services/CategoryService.kt index 4df2eaa..6ebd664 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/services/CategoryService.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/services/CategoryService.kt @@ -21,11 +21,10 @@ interface CategoryService { @Query("ownerType") ownerType: String, @Query("ownerId") ownerId: Long, @Query("type") type: String? = null - ) : List + ): List - @POST("users/{userId}/categories") + @POST("categories") suspend fun createCategory( - @Path("userId") userId: Long, @Body request: CreateCategoryRequestDto ): CategoryResponseDto -} \ No newline at end of file +} From b5e8c7ba9ba14a5af2840e617aff14e278389877 Mon Sep 17 00:00:00 2001 From: Giovany7x Date: Thu, 11 Jun 2026 22:39:53 -0500 Subject: [PATCH 10/15] fix(category): align create request dto --- .../data/remote/models/CreateCategoryRequestDto.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/CreateCategoryRequestDto.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/CreateCategoryRequestDto.kt index 347a12f..e762c8b 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/CreateCategoryRequestDto.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/CreateCategoryRequestDto.kt @@ -4,9 +4,10 @@ import com.google.gson.annotations.SerializedName data class CreateCategoryRequestDto( @SerializedName("name") val name: String, - @SerializedName("ownerType") val ownerType: String = "USER", + @SerializedName("ownerType") val ownerType: String, + @SerializedName("ownerId") val ownerId: Long, @SerializedName("description") val description: String = "", @SerializedName("color") val color: String = "#534AB7", @SerializedName("icon") val icon: String, - @SerializedName("isActive") val isActive: Boolean = true + @SerializedName("type") val type: String ) \ No newline at end of file From da5bce1bf82bbc14816cdb72172305f55703f072 Mon Sep 17 00:00:00 2001 From: Giovany7x Date: Thu, 11 Jun 2026 22:40:10 -0500 Subject: [PATCH 11/15] fix(category): update facade creation flow --- .../data/remote/CategoryFacadeService.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/CategoryFacadeService.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/CategoryFacadeService.kt index 395b9e3..8c68f2e 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/CategoryFacadeService.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/CategoryFacadeService.kt @@ -22,10 +22,8 @@ class CategoryFacadeService @Inject constructor( ) suspend fun createCategory( - userId: Long, request: CreateCategoryRequestDto ) = categoryService.createCategory( - userId = userId, request = request ) } \ No newline at end of file From 7c8c7114ac1dc50824f27184c0dcb19959898baa Mon Sep 17 00:00:00 2001 From: Giovany7x Date: Thu, 11 Jun 2026 22:40:31 -0500 Subject: [PATCH 12/15] fix(category): send owner and type on creation --- .../repositories/CategoryRepositoryImpl.kt | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/repositories/CategoryRepositoryImpl.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/repositories/CategoryRepositoryImpl.kt index 1c0041d..9fb5cbd 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/repositories/CategoryRepositoryImpl.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/repositories/CategoryRepositoryImpl.kt @@ -2,6 +2,7 @@ package com.resolum.intiva.features.paymentmethodsandcategories.data.repositorie import com.resolum.intiva.core.data.repository.BaseRepository import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.finances.domain.models.TransactionType import com.resolum.intiva.features.iam.domain.repositories.SessionRepository import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.CategoryFacadeService import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.mappers.toDomain @@ -9,6 +10,7 @@ import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.model import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.services.CategoryService import com.resolum.intiva.features.paymentmethodsandcategories.domain.models.Category import com.resolum.intiva.features.paymentmethodsandcategories.domain.repositories.CategoryRepository +import com.resolum.intiva.features.shared.domain.model.OwnerType import javax.inject.Inject /** @@ -26,20 +28,20 @@ class CategoryRepositoryImpl @Inject constructor( private val sessionRepository: SessionRepository ) : BaseRepository(), CategoryRepository { - /** - * Fetches the list of categories associated with the current user. It retrieves the user ID - * from the session repository and then calls the category facade service to get the categories. - * The result is mapped to a list of domain models and returned as a [NetworkResult]. - * - * @return A [NetworkResult] containing a list of [Category] objects on success, or an error message on failure. - */ - override suspend fun getCategoriesByOwnerId(ownerType: String, type: String): NetworkResult> = + override suspend fun getCategoriesByOwnerId( + ownerType: String, + type: String + ): NetworkResult> = safeCall { - val userId = sessionRepository.getUserId() ?: throw IllegalStateException("No active session found") - categoryFacadeService.getCategoriesByOwnerId(ownerType, userId, type).map { it.toDomain() } - } - + val userId = sessionRepository.getUserId() + ?: throw IllegalStateException("No active session found") + categoryFacadeService.getCategoriesByOwnerId( + ownerType = ownerType, + ownerId = userId, + type = type + ).map { it.toDomain() } + } override suspend fun createCategory( name: String, @@ -53,13 +55,14 @@ class CategoryRepositoryImpl @Inject constructor( val request = CreateCategoryRequestDto( name = name, - ownerType = "USER", + ownerType = OwnerType.INDIVIDUAL.name, + ownerId = userId, description = description, color = color, icon = icon, - isActive = true + type = TransactionType.EXPENSE.name ) - categoryFacadeService.createCategory(userId, request).toDomain() + categoryFacadeService.createCategory(request).toDomain() } } \ No newline at end of file From e28a9248f9df5c296e5f200156131528c666c042 Mon Sep 17 00:00:00 2001 From: Giovany7x Date: Thu, 11 Jun 2026 22:40:46 -0500 Subject: [PATCH 13/15] fix(category): load expense categories by default --- .../category/CategoryViewModel.kt | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/category/CategoryViewModel.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/category/CategoryViewModel.kt index dccf31d..5938f55 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/category/CategoryViewModel.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/category/CategoryViewModel.kt @@ -3,9 +3,10 @@ package com.resolum.intiva.features.paymentmethodsandcategories.presentation.cat import com.resolum.intiva.core.common.state.UiState import com.resolum.intiva.core.common.viewmodel.BaseViewModel import com.resolum.intiva.core.network.model.NetworkResult -import com.resolum.intiva.features.paymentmethodsandcategories.domain.models.Category +import com.resolum.intiva.features.finances.domain.models.TransactionType import com.resolum.intiva.features.paymentmethodsandcategories.domain.usecases.CreateCategoryUseCase import com.resolum.intiva.features.paymentmethodsandcategories.domain.usecases.GetCategoriesUseCase +import com.resolum.intiva.features.shared.domain.model.OwnerType import dagger.hilt.android.lifecycle.HiltViewModel import jakarta.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -22,23 +23,32 @@ class CategoryViewModel @Inject constructor( private val _uiState = MutableStateFlow(CategoryUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun getCategories(ownerType: String = "", type: String = "") { + fun getCategories( + ownerType: String = OwnerType.INDIVIDUAL.name, + type: String = TransactionType.EXPENSE.name + ) { safeLaunch { _uiState.update { it.copy(categoriesState = UiState.Loading) } + when (val result = getCategoriesUseCase(ownerType, type)) { - is NetworkResult.Success -> _uiState.update { - it.copy( - categories = result.data, - categoriesState = UiState.Success(result.data) - ) + is NetworkResult.Success -> { + _uiState.update { + it.copy( + categories = result.data, + categoriesState = UiState.Success(result.data) + ) + } } - is NetworkResult.Error -> _uiState.update { - it.copy( - categoriesState = UiState.Error( - message = result.message, - throwable = result.throwable + + is NetworkResult.Error -> { + _uiState.update { + it.copy( + categoriesState = UiState.Error( + message = result.message, + throwable = result.throwable + ) ) - ) + } } } } @@ -48,7 +58,11 @@ class CategoryViewModel @Inject constructor( _uiState.update { it.copy( name = value, - nameError = if (value.isBlank()) "El nombre de la categor铆a es obligatorio" else null + nameError = if (value.isBlank()) { + "El nombre de la categor铆a es obligatorio" + } else { + null + } ) } } @@ -110,9 +124,11 @@ class CategoryViewModel @Inject constructor( nameError = null ) } + getCategories() onSuccess() } + is NetworkResult.Error -> { _uiState.update { it.copy( From 0f6a8dcd8ae9732325bb546acc192fc28ad9307e Mon Sep 17 00:00:00 2001 From: Far14z Date: Thu, 11 Jun 2026 23:09:28 -0500 Subject: [PATCH 14/15] feat(transactions): implement transaction detail screen and add transaction fetching logic. --- .../intiva/core/navigation/graph/MainShell.kt | 47 +- .../core/navigation/routes/NavRoutes.kt | 3 + .../data/remote/TransactionFacadeService.kt | 4 +- .../remote/mappers/TransactionResponseDto.kt | 6 +- .../remote/models/TransactionResponseDto.kt | 12 +- .../remote/services/TransactionService.kt | 7 +- .../repositories/TransactionRepositoryImpl.kt | 10 +- .../finances/domain/models/Transaction.kt | 7 +- .../repositories/TransactionRepository.kt | 4 +- .../usecase/GetTransactionByIdUseCase.kt | 14 + .../finances/presentation/HomeScreen.kt | 10 +- .../transactions/TransactionDetailScreen.kt | 384 ++++++++++++++ .../transactions/TransactionFilterModels.kt | 96 ++++ .../transactions/TransactionUiState.kt | 1 + .../transactions/TransactionsScreen.kt | 235 ++++++--- .../transactions/TransactionsViewModel.kt | 31 +- .../components/AppliedFiltersHeader.kt | 64 +++ .../components/TransactionDateSection.kt | 13 +- .../components/TransactionFilterIconMapper.kt | 25 + .../components/TransactionFiltersSheet.kt | 480 ++++++++++++++++++ .../components/TransactionItem.kt | 10 +- 21 files changed, 1376 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/com/resolum/intiva/features/finances/domain/usecase/GetTransactionByIdUseCase.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionDetailScreen.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionFilterModels.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/AppliedFiltersHeader.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionFilterIconMapper.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionFiltersSheet.kt diff --git a/app/src/main/java/com/resolum/intiva/core/navigation/graph/MainShell.kt b/app/src/main/java/com/resolum/intiva/core/navigation/graph/MainShell.kt index e50ddc2..f2e78ac 100644 --- a/app/src/main/java/com/resolum/intiva/core/navigation/graph/MainShell.kt +++ b/app/src/main/java/com/resolum/intiva/core/navigation/graph/MainShell.kt @@ -17,6 +17,7 @@ import com.resolum.intiva.features.finances.domain.models.TransactionType import com.resolum.intiva.features.finances.presentation.HomeScreen import com.resolum.intiva.features.finances.presentation.spendinglimits.SpendingLimitScreen import com.resolum.intiva.features.finances.presentation.transactions.TransactionFormScreen +import com.resolum.intiva.features.finances.presentation.transactions.TransactionDetailScreen import com.resolum.intiva.features.finances.presentation.transactions.TransactionsScreen import com.resolum.intiva.features.paymentmethodsandcategories.presentation.category.ManageCategoriesScreen import com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount.CreateFinancialAccountScreen @@ -53,16 +54,19 @@ fun MainShell( Scaffold( containerColor = Color.White, bottomBar = { - IntivaBottomNavBar( - currentRoute = currentRoute, - onNavigate = { route -> - shellNavController.navigate(route) { - popUpTo(NavRoutes.HOME) { inclusive = false } - launchSingleTop = true - } - }, - ) - }, contentWindowInsets = WindowInsets(0) + if (currentRoute in NavRoutes.BOTTOM_NAV_ROUTES) { + IntivaBottomNavBar( + currentRoute = currentRoute, + onNavigate = { route -> + shellNavController.navigate(route) { + popUpTo(NavRoutes.HOME) { inclusive = false } + launchSingleTop = true + } + }, + ) + } + }, + contentWindowInsets = WindowInsets(0) ) { padding -> NavHost( navController = shellNavController, @@ -79,11 +83,29 @@ fun MainShell( onNavigateToSpendingLimitAlert = { shellNavController.navigate(NavRoutes.SPENDING_LIMIT_ALERT) }, + onNavigateToTransactionDetail = { transactionId -> + shellNavController.navigate(NavRoutes.transactionDetail(transactionId)) + }, ) } composable(NavRoutes.TRANSACTIONS) { - TransactionsScreen() + TransactionsScreen( + onNavigateToTransactionDetail = { transactionId -> + shellNavController.navigate(NavRoutes.transactionDetail(transactionId)) + } + ) + } + + composable(NavRoutes.TRANSACTION_DETAIL) { backStackEntry -> + val transactionId = backStackEntry.arguments + ?.getString("transactionId") + ?.toLongOrNull() + ?: return@composable + TransactionDetailScreen( + transactionId = transactionId, + onNavigateBack = { shellNavController.popBackStack() } + ) } composable(NavRoutes.SPENDING_LIMIT_ALERT) { @@ -310,6 +332,7 @@ fun MainShell( ownerType = OwnerType.INDIVIDUAL ) } + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/resolum/intiva/core/navigation/routes/NavRoutes.kt b/app/src/main/java/com/resolum/intiva/core/navigation/routes/NavRoutes.kt index 244dd2b..d6ae39d 100644 --- a/app/src/main/java/com/resolum/intiva/core/navigation/routes/NavRoutes.kt +++ b/app/src/main/java/com/resolum/intiva/core/navigation/routes/NavRoutes.kt @@ -6,6 +6,7 @@ package com.resolum.intiva.core.navigation.routes object NavRoutes { const val HOME = "home" const val TRANSACTIONS = "transactions" + const val TRANSACTION_DETAIL = "transaction_detail/{transactionId}" const val SPENDING_LIMIT_ALERT = "spending_limit_alert" const val SAVINGS_GOALS = "savings_goals" const val SAVINGS_GOAL_CREATE = "savings_goal_create/{accountId}" @@ -26,6 +27,8 @@ object NavRoutes { const val NEW_INCOME = "transactions/new_income" const val NEW_EXPENSE = "transaction/new_expense" + fun transactionDetail(transactionId: Long) = "transaction_detail/$transactionId" + const val MANAGE_CATEGORIES = "manage_categories" const val FINANCIAL_ACCOUNTS = "financial_accounts" diff --git a/app/src/main/java/com/resolum/intiva/features/finances/data/remote/TransactionFacadeService.kt b/app/src/main/java/com/resolum/intiva/features/finances/data/remote/TransactionFacadeService.kt index dc36631..ac09484 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/data/remote/TransactionFacadeService.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/data/remote/TransactionFacadeService.kt @@ -33,6 +33,8 @@ class TransactionFacadeService @Inject constructor( */ suspend fun getTransactionsByOwnerId(ownerId: Long, transactionType: String?) = transactionService.getTransactionsByOwnerId(ownerId, transactionType) + suspend fun getTransactionById(id: Long) = transactionService.getTransactionById(id) + /** * Retrieves the latest transactions for a specific owner. * @@ -41,4 +43,4 @@ class TransactionFacadeService @Inject constructor( */ suspend fun getLastestTransactionByOwnerId(ownerId: Long) = transactionService.getLastestTransactionByOwnerId(ownerId) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/data/remote/mappers/TransactionResponseDto.kt b/app/src/main/java/com/resolum/intiva/features/finances/data/remote/mappers/TransactionResponseDto.kt index e002099..6c36a19 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/data/remote/mappers/TransactionResponseDto.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/data/remote/mappers/TransactionResponseDto.kt @@ -3,6 +3,7 @@ package com.resolum.intiva.features.finances.data.remote.mappers import com.resolum.intiva.features.finances.data.remote.models.TransactionResponseDto import com.resolum.intiva.features.finances.data.remote.models.TransactionWithDesignResponseDto import com.resolum.intiva.features.finances.domain.models.Transaction +import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.mappers.toDomain /** * Extension function to map a [Transaction] domain model to a [TransactionWithDesignResponseDto]. @@ -19,8 +20,11 @@ fun TransactionResponseDto.toDomain(): Transaction { description = description, ownerId = ownerId, financialAccountId = financialAccountId, + financialAccountName = financialAccountName, actorUserId = actorUserId, transactionType = transactionType, categoryId = categoryId, + registeredAt = registeredAt, + financialAccount = financialAccount?.toDomain(), ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/data/remote/models/TransactionResponseDto.kt b/app/src/main/java/com/resolum/intiva/features/finances/data/remote/models/TransactionResponseDto.kt index 9b3d09a..7550c87 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/data/remote/models/TransactionResponseDto.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/data/remote/models/TransactionResponseDto.kt @@ -1,6 +1,7 @@ package com.resolum.intiva.features.finances.data.remote.models import com.google.gson.annotations.SerializedName +import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.models.FinancialAccountResponseDto /** * Data Transfer Object (DTO) representing a financial transaction response from the API. @@ -35,6 +36,9 @@ data class TransactionResponseDto( @SerializedName("financialAccountId") val financialAccountId: Long, + @SerializedName("financialAccountName") + val financialAccountName: String? = null, + @SerializedName("actorUserId") val actorUserId: Long, @@ -43,4 +47,10 @@ data class TransactionResponseDto( @SerializedName("categoryId") val categoryId: Long?, -) \ No newline at end of file + + @SerializedName("registeredAt") + val registeredAt: String? = null, + + @SerializedName(value = "financialAccount", alternate = ["account"]) + val financialAccount: FinancialAccountResponseDto? = null, +) diff --git a/app/src/main/java/com/resolum/intiva/features/finances/data/remote/services/TransactionService.kt b/app/src/main/java/com/resolum/intiva/features/finances/data/remote/services/TransactionService.kt index 3dfaede..9bc0342 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/data/remote/services/TransactionService.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/data/remote/services/TransactionService.kt @@ -43,6 +43,11 @@ interface TransactionService { @Query("transactionType") transactionType: String? = null, ) : ResponseWrapperDto> + @GET("transactions/{id}") + suspend fun getTransactionById( + @Path("id") id: Long, + ) : TransactionResponseDto + /** * Makes a GET request to the "transactions/lastest" endpoint to retrieve the latest transactions for a specific owner. * @@ -54,4 +59,4 @@ interface TransactionService { @Query("ownerId") ownerId: Long, ) : ResponseWrapperDto> -} \ No newline at end of file +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/data/repositories/TransactionRepositoryImpl.kt b/app/src/main/java/com/resolum/intiva/features/finances/data/repositories/TransactionRepositoryImpl.kt index 87f1c50..9de74f2 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/data/repositories/TransactionRepositoryImpl.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/data/repositories/TransactionRepositoryImpl.kt @@ -72,6 +72,14 @@ class TransactionRepositoryImpl @Inject constructor( return result } + override suspend fun getTransactionById(id: Long): NetworkResult { + val result = safeCall { + transactionFacadeService.getTransactionById(id).toDomain() + } + + return result + } + /** * Retrieves the latest transactions for a specific owner by making an API call through the [TransactionFacadeService]. * @@ -93,4 +101,4 @@ class TransactionRepositoryImpl @Inject constructor( return result } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/domain/models/Transaction.kt b/app/src/main/java/com/resolum/intiva/features/finances/domain/models/Transaction.kt index 1e538ab..a7fd728 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/domain/models/Transaction.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/domain/models/Transaction.kt @@ -1,5 +1,7 @@ package com.resolum.intiva.features.finances.domain.models +import com.resolum.intiva.features.paymentmethodsandcategories.domain.models.FinancialAccount + /** * Represents a financial transaction resource with all its details. * @@ -20,7 +22,10 @@ data class Transaction( val description: String, val ownerId: Long, val financialAccountId: Long, + val financialAccountName: String? = null, val actorUserId: Long, val transactionType: String, - val categoryId: Long? + val categoryId: Long?, + val registeredAt: String? = null, + val financialAccount: FinancialAccount? = null ) diff --git a/app/src/main/java/com/resolum/intiva/features/finances/domain/repositories/TransactionRepository.kt b/app/src/main/java/com/resolum/intiva/features/finances/domain/repositories/TransactionRepository.kt index 3449891..c1ea39d 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/domain/repositories/TransactionRepository.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/domain/repositories/TransactionRepository.kt @@ -29,10 +29,12 @@ interface TransactionRepository { */ suspend fun getTransactionsByOwnerId(transactionType: String?) : NetworkResult> + suspend fun getTransactionById(id: Long): NetworkResult + /** * Retrieves the latest transactions for a specific owner. * * @return A [NetworkResult] containing a list of [TransactionGroupByDate] representing the latest transactions if successful, or an error if the operation fails. */ suspend fun getLastestTransactionsByOwnerId() : NetworkResult> -} \ No newline at end of file +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/domain/usecase/GetTransactionByIdUseCase.kt b/app/src/main/java/com/resolum/intiva/features/finances/domain/usecase/GetTransactionByIdUseCase.kt new file mode 100644 index 0000000..94c4998 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/finances/domain/usecase/GetTransactionByIdUseCase.kt @@ -0,0 +1,14 @@ +package com.resolum.intiva.features.finances.domain.usecase + +import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.finances.domain.models.Transaction +import com.resolum.intiva.features.finances.domain.repositories.TransactionRepository +import jakarta.inject.Inject + +class GetTransactionByIdUseCase @Inject constructor( + private val transactionRepository: TransactionRepository +) { + suspend operator fun invoke(id: Long): NetworkResult { + return transactionRepository.getTransactionById(id) + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/HomeScreen.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/HomeScreen.kt index 64cf023..8632a5a 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/presentation/HomeScreen.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/HomeScreen.kt @@ -98,7 +98,8 @@ fun HomeScreen( spendingLimitViewModel: SpendingLimitViewModel = hiltViewModel(), profileViewModel: ProfileViewModel = hiltViewModel(), onNavigateToTransactions: () -> Unit, - onNavigateToSpendingLimitAlert: () -> Unit = {} + onNavigateToSpendingLimitAlert: () -> Unit = {}, + onNavigateToTransactionDetail: (Long) -> Unit = {} ) { val snackBarHostState = remember { SnackbarHostState() } val uiState by viewModel.uiState.collectAsState() @@ -392,7 +393,12 @@ fun HomeScreen( } else { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { allTransactions.forEach { transaction -> - TransactionItem(transaction = transaction) + TransactionItem( + transaction = transaction, + onClick = { selectedTransaction -> + onNavigateToTransactionDetail(selectedTransaction.id) + } + ) } } } diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionDetailScreen.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionDetailScreen.kt new file mode 100644 index 0000000..a56fed8 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionDetailScreen.kt @@ -0,0 +1,384 @@ +package com.resolum.intiva.features.finances.presentation.transactions + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.shape.CircleShape +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.CheckCircleOutline +import androidx.compose.material.icons.filled.ReportProblem +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Storefront +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.resolum.intiva.core.common.state.UiState +import com.resolum.intiva.core.ui.theme.IntivaColors +import com.resolum.intiva.features.finances.domain.models.Transaction +import com.resolum.intiva.features.finances.domain.models.TransactionType +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TransactionDetailScreen( + transactionId: Long, + onNavigateBack: () -> Unit, + viewModel: TransactionViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(transactionId) { + viewModel.getTransactionById(transactionId) + } + + Scaffold( + containerColor = Color.White, + topBar = { + TopAppBar( + title = { + Text( + text = "Resumen", + color = IntivaColors.TextPrimary, + fontSize = 20.sp, + fontWeight = FontWeight.Medium + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Volver", + tint = Color(0xFF4F4A58) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.White + ) + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + ) { + when (val state = uiState.transactionDetailState) { + is UiState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = IntivaColors.PrimaryBrand + ) + } + + is UiState.Success -> { + TransactionDetailContent( + transaction = state.data, + modifier = Modifier.fillMaxSize() + ) + } + + is UiState.Error -> { + Text( + text = state.message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.align(Alignment.Center), + textAlign = TextAlign.Center + ) + } + + else -> Unit + } + } + } +} + +@Composable +private fun TransactionDetailContent( + transaction: Transaction, + modifier: Modifier = Modifier +) { + val isIncome = transaction.transactionType == TransactionType.INCOME.name + val amountPrefix = if (isIncome) "+" else "-" + val amountColor = if (isIncome) Color(0xFF42A94F) else IntivaColors.TextPrimary + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(36.dp)) + + Box( + modifier = Modifier + .size(76.dp) + .clip(CircleShape) + .background(Color(0xFFEFE9F7)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Storefront, + contentDescription = null, + tint = IntivaColors.PrimaryBrand, + modifier = Modifier.size(34.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = transaction.currencyCode, + color = Color(0xFF817A8D), + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold + ) + Text( + text = " $amountPrefix${transaction.amount.formatAmount()}", + color = amountColor, + fontSize = 34.sp, + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.height(38.dp)) + + DetailCard(transaction = transaction) + + Spacer(modifier = Modifier.weight(1f)) + + ShareButton() + + TextButton( + onClick = { /* TODO */ }, + modifier = Modifier.padding(top = 18.dp, bottom = 22.dp) + ) { + Icon( + imageVector = Icons.Default.ReportProblem, + contentDescription = null, + tint = IntivaColors.PrimaryBrand, + modifier = Modifier.size(19.dp) + ) + Text( + text = " Reportar problema", + color = IntivaColors.PrimaryBrand, + fontWeight = FontWeight.SemiBold + ) + } + } +} + +@Composable +private fun DetailCard(transaction: Transaction) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) { + val detailDateTime = transaction.registeredAt?.toDetailDateTime() + DetailRow( + label = "Descripcion", + value = transaction.description.ifBlank { "Transaccion #${transaction.id}" } + ) + DetailRow( + label = "Fecha", + value = detailDateTime?.first ?: "No disponible" + ) + DetailRow( + label = "Hora", + value = detailDateTime?.second ?: "No disponible" + ) + DetailRow( + label = "Cuenta origen", + value = transaction.financialAccountName + ?: transaction.financialAccount?.name + ?: "Cuenta #${transaction.financialAccountId}", + supportingValue = transaction.financialAccount?.institution + ?: transaction.financialAccount?.accountType + ) + DetailRow( + label = "Estado", + value = "Completado", + chip = true, + showDivider = false + ) + } + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, + supportingValue: String? = null, + chip: Boolean = false, + showDivider: Boolean = true +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + color = Color(0xFF565160), + fontSize = 17.sp + ) + + if (chip) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(50.dp)) + .background(Color(0xFFE8E1FF)) + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CheckCircleOutline, + contentDescription = null, + tint = IntivaColors.PrimaryBrand, + modifier = Modifier.size(16.dp) + ) + Text( + text = " $value", + color = IntivaColors.PrimaryBrand, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp + ) + } + } else { + Column(horizontalAlignment = Alignment.End) { + Text( + text = value, + color = IntivaColors.TextPrimary, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.End + ) + supportingValue?.let { + Text( + text = it, + color = Color(0xFF817A8D), + fontSize = 13.sp, + textAlign = TextAlign.End + ) + } + } + } + } + + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color(0xFFE9E4EF)) + ) + } + } +} + +@Composable +private fun ShareButton() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clip(RoundedCornerShape(30.dp)) + .background(IntivaColors.PrimaryAction), + contentAlignment = Alignment.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = null, + tint = Color(0xFF6E7A13) + ) + Text( + text = " COMPARTIR", + color = Color(0xFF6E7A13), + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp + ) + } + } +} + +private fun String.formatAmount(): String { + return runCatching { + BigDecimal(this).setScale(2).toPlainString() + }.getOrElse { this } +} + +private fun String.toDetailDateTime(): Pair? { + val dateFormatter = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.forLanguageTag("es-PE")) + val timeFormatter = DateTimeFormatter.ofPattern("hh:mm a", Locale.forLanguageTag("es-PE")) + return runCatching { + val dateTime = Instant.parse(this).atZone(ZoneId.systemDefault()).toLocalDateTime() + dateTime.toFormattedPair(dateFormatter, timeFormatter) + }.recoverCatching { + val dateTime = OffsetDateTime.parse(this).atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime() + dateTime.toFormattedPair(dateFormatter, timeFormatter) + }.recoverCatching { + LocalDateTime.parse(this).toFormattedPair(dateFormatter, timeFormatter) + }.getOrNull() +} + +private fun LocalDateTime.toFormattedPair( + dateFormatter: DateTimeFormatter, + timeFormatter: DateTimeFormatter +): Pair { + val date = toLocalDate().format(dateFormatter) + val time = toLocalTime().format(timeFormatter) + .replace("a. m.", "AM") + .replace("p. m.", "PM") + return date to time +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionFilterModels.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionFilterModels.kt new file mode 100644 index 0000000..f8c0544 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionFilterModels.kt @@ -0,0 +1,96 @@ +package com.resolum.intiva.features.finances.presentation.transactions + +import androidx.compose.ui.graphics.vector.ImageVector +import com.resolum.intiva.features.finances.domain.models.TransactionGroupByDate +import com.resolum.intiva.features.finances.domain.models.TransactionType +import java.time.Instant +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale + +enum class FilterOption(val title: String, val type: TransactionType?) { + ALL("Todos", null), + INCOME("Ingresos", TransactionType.INCOME), + EXPENSE("Gastos", TransactionType.EXPENSE) +} + +enum class DateRangeOption(val title: String) { + THIS_MONTH("Este mes"), + LAST_MONTH("Mes pasado"), + LAST_THREE_MONTHS("Ultimos 3 meses") +} + +data class TransactionFilters( + val dateRangeOption: DateRangeOption = DateRangeOption.THIS_MONTH, + val fromDate: LocalDate? = YearMonth.now().atDay(1), + val toDate: LocalDate? = LocalDate.now(), + val type: FilterOption = FilterOption.ALL, + val categoryIds: Set = emptySet() +) + +data class FilterCategory( + val id: Long, + val name: String, + val icon: ImageVector +) + +fun List.filterWith( + filters: TransactionFilters +): List { + return mapNotNull { group -> + val groupDate = group.date.toLocalDateOrNull() + val filteredTransactions = group.transactions.filter { transaction -> + val transactionDate = transaction.registeredAt.toLocalDateOrNull() ?: groupDate + val matchesDate = transactionDate == null || + ((filters.fromDate == null || !transactionDate.isBefore(filters.fromDate)) && + (filters.toDate == null || !transactionDate.isAfter(filters.toDate))) + val matchesCategory = filters.categoryIds.isEmpty() || + transaction.categoryId in filters.categoryIds + + matchesDate && matchesCategory + } + + if (filteredTransactions.isEmpty()) { + null + } else { + group.copy(transactions = filteredTransactions) + } + } +} + +fun DateRangeOption.toDates(): Pair { + val today = LocalDate.now() + return when (this) { + DateRangeOption.THIS_MONTH -> YearMonth.from(today).atDay(1) to today + DateRangeOption.LAST_MONTH -> { + val lastMonth = YearMonth.from(today).minusMonths(1) + lastMonth.atDay(1) to lastMonth.atEndOfMonth() + } + DateRangeOption.LAST_THREE_MONTHS -> today.minusMonths(3) to today + } +} + +fun String.toLocalDateOrNull(): LocalDate? { + return runCatching { + Instant.parse(this).atZone(ZoneId.systemDefault()).toLocalDate() + }.getOrElse { + runCatching { LocalDate.parse(take(10)) }.getOrNull() + } +} + +fun LocalDate.toDatePickerMillis(): Long { + return atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() +} + +fun Long.toDatePickerLocalDate(): LocalDate { + return Instant.ofEpochMilli(this).atZone(ZoneOffset.UTC).toLocalDate() +} + +fun LocalDate.formatFilterDate(): String { + return format(DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.forLanguageTag("es-PE"))) + .replace(".", "") + .replaceFirstChar { it.uppercase() } +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionUiState.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionUiState.kt index 74c35b9..4bf9e4a 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionUiState.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionUiState.kt @@ -14,5 +14,6 @@ import com.resolum.intiva.features.finances.domain.models.TransactionGroupByDate data class TransactionUiState( val state: UiState = UiState.Idle, val transactionsState: UiState> = UiState.Idle, + val transactionDetailState: UiState = UiState.Idle, val navigateBack: Boolean = false ) diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionsScreen.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionsScreen.kt index c99b43c..9b1ddce 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionsScreen.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionsScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -18,24 +17,33 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Business +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.LaptopMac +import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.NotificationsNone +import androidx.compose.material.icons.filled.Restaurant +import androidx.compose.material.icons.filled.Work import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState 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.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -50,36 +58,78 @@ import coil3.compose.AsyncImage import com.resolum.intiva.core.common.state.UiState import com.resolum.intiva.core.ui.theme.IntivaColors import com.resolum.intiva.features.finances.domain.models.TransactionType +import com.resolum.intiva.features.finances.presentation.transactions.components.AppliedFiltersHeader import com.resolum.intiva.features.finances.presentation.transactions.components.EmptyTransactionsContent +import com.resolum.intiva.features.finances.presentation.transactions.components.TransactionFiltersSheet +import com.resolum.intiva.features.finances.presentation.transactions.components.iconForTransactionCategory +import com.resolum.intiva.features.paymentmethodsandcategories.presentation.category.CategoryViewModel import com.resolum.intiva.features.profiles.presentation.ProfileViewModel +import kotlinx.coroutines.launch -enum class FilterOption(val title: String, val type: TransactionType?) { - ALL("Todos", null), - INCOME("Ingresos", TransactionType.INCOME), - EXPENSE("Gastos", TransactionType.EXPENSE) -} - -/** - * Composable function representing the transactions screen, which displays a list of financial transactions grouped by date. - * - * The screen includes a top app bar with the app name and a notifications icon, as well as filter chips to allow users to filter transactions by type (all, income, expenses). - * - * @param viewModel The ViewModel managing the state and logic for this screen, provided by Hilt. - */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun TransactionsScreen( viewModel: TransactionViewModel = hiltViewModel(), - profileViewModel: ProfileViewModel = hiltViewModel() + profileViewModel: ProfileViewModel = hiltViewModel(), + categoryViewModel: CategoryViewModel = hiltViewModel(), + onNavigateToTransactionDetail: (Long) -> Unit = {} ) { val uiState by viewModel.uiState.collectAsState() val profileUiState by profileViewModel.uiState.collectAsState() - var selectedFilter by remember { mutableStateOf(FilterOption.ALL) } + val categoryUiState by categoryViewModel.uiState.collectAsState() + var appliedFilters by remember { mutableStateOf(TransactionFilters()) } + var draftFilters by remember { mutableStateOf(appliedFilters) } + var showFilters by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val categories = remember(categoryUiState.categories, appliedFilters.type) { + val loadedCategories = categoryUiState.categories + .filter { it.isActive } + .map { category -> + FilterCategory( + id = category.id, + name = category.name, + icon = iconForTransactionCategory(category.name) + ) + } + + categoriesForFilter( + type = appliedFilters.type, + loadedCategories = loadedCategories + ) + } + + val draftCategories = remember(categoryUiState.categories, draftFilters.type) { + val loadedCategories = categoryUiState.categories + .filter { it.isActive } + .map { category -> + FilterCategory( + id = category.id, + name = category.name, + icon = iconForTransactionCategory(category.name) + ) + } - LaunchedEffect(selectedFilter) { + categoriesForFilter( + type = draftFilters.type, + loadedCategories = loadedCategories + ) + } + + LaunchedEffect(draftFilters.type, showFilters) { + if (showFilters && draftFilters.type != appliedFilters.type) { + val categoryType = draftFilters.type.type ?: TransactionType.EXPENSE + categoryViewModel.getCategories(type = categoryType.name) + } + } + + LaunchedEffect(appliedFilters.type) { viewModel.getTransactionsByOwnerId( - transactionType = selectedFilter.type + transactionType = appliedFilters.type.type ) + + val categoryType = appliedFilters.type.type ?: TransactionType.EXPENSE + categoryViewModel.getCategories(type = categoryType.name) } LaunchedEffect(Unit) { @@ -95,7 +145,8 @@ fun TransactionsScreen( val profile = (profileUiState.profileState as? UiState.Success)?.data val avatarUrl = profile?.avatarUrl?.ifEmpty { null } AsyncImage( - model = avatarUrl ?: "https://res.cloudinary.com/dcppsmlzd/image/upload/v1781121388/avatar_default_kf0yvc.png", + model = avatarUrl + ?: "https://res.cloudinary.com/dcppsmlzd/image/upload/v1781121388/avatar_default_kf0yvc.png", contentDescription = "Avatar", modifier = Modifier .size(32.dp) @@ -112,6 +163,19 @@ fun TransactionsScreen( } }, actions = { + IconButton( + onClick = { + draftFilters = appliedFilters + showFilters = true + } + ) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = "Filtros", + tint = IntivaColors.IconPurple, + modifier = Modifier.size(26.dp) + ) + } IconButton(onClick = { /* TODO */ }) { Icon( imageVector = Icons.Default.NotificationsNone, @@ -134,53 +198,31 @@ fun TransactionsScreen( .background(Color.White) .padding(innerPadding) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterOption.entries.forEach { option -> - val isSelected = selectedFilter == option - - FilterChip( - selected = isSelected, - onClick = { selectedFilter = option }, - label = { - Text( - text = option.title, - fontWeight = FontWeight.Medium, - fontSize = 16.sp - ) - }, - shape = RoundedCornerShape(50.dp), - colors = FilterChipDefaults.filterChipColors( - containerColor = Color.Transparent, - labelColor = IntivaColors.UnselectedChipText, - selectedContainerColor = IntivaColors.PrimaryBrand, - selectedLabelColor = IntivaColors.SelectedChipText - ), - border = FilterChipDefaults.filterChipBorder( - enabled = true, - selected = isSelected, - borderColor = IntivaColors.UnselectedChipBorder, - selectedBorderColor = Color.Transparent, - borderWidth = 1.dp - ) + AppliedFiltersHeader( + filters = appliedFilters, + onFilterSelected = { option -> + appliedFilters = appliedFilters.copy( + type = option, + categoryIds = emptySet() ) } - } + ) Box( modifier = Modifier.fillMaxSize() ) { when (val state = uiState.transactionsState) { is UiState.Loading -> { - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = IntivaColors.PrimaryBrand + ) } is UiState.Success -> { - val groupedTransactions = state.data + val groupedTransactions = remember(state.data, appliedFilters) { + state.data.filterWith(appliedFilters) + } if (groupedTransactions.isEmpty()) { EmptyTransactionsContent() @@ -194,7 +236,12 @@ fun TransactionsScreen( verticalArrangement = Arrangement.spacedBy(20.dp) ) { items(groupedTransactions) { group -> - TransactionDateSection(group = group) + TransactionDateSection( + group = group, + onTransactionClick = { transaction -> + onNavigateToTransactionDetail(transaction.id) + } + ) } } } @@ -218,5 +265,77 @@ fun TransactionsScreen( } } } + + if (showFilters) { + ModalBottomSheet( + onDismissRequest = { showFilters = false }, + sheetState = sheetState, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + containerColor = Color.White + ) { + TransactionFiltersSheet( + filters = draftFilters, + categories = draftCategories, + onFiltersChange = { draftFilters = it }, + onClearCategories = { + draftFilters = draftFilters.copy(categoryIds = emptySet()) + }, + onClose = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + showFilters = false + } + }, + onApply = { + appliedFilters = draftFilters + scope.launch { sheetState.hide() }.invokeOnCompletion { + showFilters = false + } + } + ) + } + } + } +} + +private fun categoriesForFilter( + type: FilterOption, + loadedCategories: List +): List { + return when (type) { + FilterOption.INCOME -> loadedCategories + .mergeWithDefaults(defaultIncomeCategories()) + .take(3) + FilterOption.EXPENSE -> loadedCategories + .mergeWithDefaults(defaultExpenseCategories()) + .take(3) + FilterOption.ALL -> defaultIncomeCategories().take(3) + defaultExpenseCategories().take(3) } } + +private fun List.mergeWithDefaults( + defaults: List +): List { + val names = map { it.name.normalizedCategoryName() }.toSet() + return this + defaults.filter { it.name.normalizedCategoryName() !in names } +} + +private fun String.normalizedCategoryName(): String { + return lowercase() + .replace("谩", "a") + .replace("茅", "e") + .replace("铆", "i") + .replace("贸", "o") + .replace("煤", "u") +} + +private fun defaultIncomeCategories() = listOf( + FilterCategory(1, "Salario", Icons.Default.Work), + FilterCategory(2, "Freelance", Icons.Default.LaptopMac), + FilterCategory(3, "Negocio", Icons.Default.Business) +) + +private fun defaultExpenseCategories() = listOf( + FilterCategory(7, "Alimentacion", Icons.Default.Restaurant), + FilterCategory(8, "Transporte", Icons.Default.DirectionsCar), + FilterCategory(9, "Salud", Icons.Default.FavoriteBorder) +) diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionsViewModel.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionsViewModel.kt index 2e21ff5..e4c9752 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionsViewModel.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/TransactionsViewModel.kt @@ -9,6 +9,7 @@ import com.resolum.intiva.core.ui.snackbar.SnackBarType import com.resolum.intiva.features.finances.domain.models.RegisterTransactionRequest import com.resolum.intiva.features.finances.domain.models.TransactionType import com.resolum.intiva.features.finances.domain.usecase.GetTransactionsByOwnerIdUseCase +import com.resolum.intiva.features.finances.domain.usecase.GetTransactionByIdUseCase import com.resolum.intiva.features.finances.domain.usecase.RegisterIndividualTransactionUseCase import com.resolum.intiva.features.paymentmethodsandcategories.domain.models.Category import com.resolum.intiva.features.paymentmethodsandcategories.domain.models.FinancialAccount @@ -31,6 +32,7 @@ import kotlinx.coroutines.launch class TransactionViewModel @Inject constructor( private val registerIndividualTransactionUseCase: RegisterIndividualTransactionUseCase, private val getTransactionsByOwnerIdUseCase: GetTransactionsByOwnerIdUseCase, + private val getTransactionByIdUseCase: GetTransactionByIdUseCase, ) : BaseViewModel() { /** Backing property for the UI state, initialized with a default TransactionUiState. */ @@ -207,4 +209,31 @@ class TransactionViewModel @Inject constructor( } } -} \ No newline at end of file + fun getTransactionById(id: Long) { + safeLaunch { + _uiState.update { + it.copy(transactionDetailState = UiState.Loading) + } + + when (val result = getTransactionByIdUseCase(id)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy(transactionDetailState = UiState.Success(result.data)) + } + } + + is NetworkResult.Error -> { + _uiState.update { + it.copy( + transactionDetailState = UiState.Error( + message = result.message, + throwable = result.throwable + ) + ) + } + } + } + } + } + +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/AppliedFiltersHeader.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/AppliedFiltersHeader.kt new file mode 100644 index 0000000..3874fce --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/AppliedFiltersHeader.kt @@ -0,0 +1,64 @@ +package com.resolum.intiva.features.finances.presentation.transactions.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +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.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.resolum.intiva.core.ui.theme.IntivaColors +import com.resolum.intiva.features.finances.presentation.transactions.FilterOption +import com.resolum.intiva.features.finances.presentation.transactions.TransactionFilters + +@Composable +fun AppliedFiltersHeader( + filters: TransactionFilters, + onFilterSelected: (FilterOption) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilterOption.entries.forEach { option -> + val isSelected = filters.type == option + + FilterChip( + selected = isSelected, + onClick = { onFilterSelected(option) }, + label = { + Text( + text = option.title, + fontWeight = FontWeight.Medium, + fontSize = 15.sp + ) + }, + shape = RoundedCornerShape(50.dp), + colors = FilterChipDefaults.filterChipColors( + containerColor = Color.Transparent, + labelColor = IntivaColors.UnselectedChipText, + selectedContainerColor = IntivaColors.PrimaryBrand, + selectedLabelColor = IntivaColors.SelectedChipText + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = isSelected, + borderColor = IntivaColors.UnselectedChipBorder, + selectedBorderColor = Color.Transparent, + borderWidth = 1.dp + ) + ) + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionDateSection.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionDateSection.kt index c931c82..2c65de2 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionDateSection.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionDateSection.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.resolum.intiva.features.finances.domain.models.TransactionGroupByDate +import com.resolum.intiva.features.finances.domain.models.TransactionWithDesignResponse import com.resolum.intiva.features.finances.presentation.transactions.components.TransactionItem import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -22,7 +23,8 @@ import java.time.format.DateTimeFormatter */ @Composable fun TransactionDateSection( - group: TransactionGroupByDate + group: TransactionGroupByDate, + onTransactionClick: (TransactionWithDesignResponse) -> Unit = {} ) { Column { Text( @@ -38,7 +40,10 @@ fun TransactionDateSection( verticalArrangement = Arrangement.spacedBy(12.dp) ) { group.transactions.forEach { transaction -> - TransactionItem(transaction = transaction) + TransactionItem( + transaction = transaction, + onClick = onTransactionClick + ) } } } @@ -57,11 +62,11 @@ private fun formatDate(date: String): String { parsedDate.format( DateTimeFormatter.ofPattern( "dd 'de' MMMM, yyyy", - java.util.Locale("es") + java.util.Locale.forLanguageTag("es-PE") ) ) }.getOrElse { date } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionFilterIconMapper.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionFilterIconMapper.kt new file mode 100644 index 0000000..cbf68e2 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionFilterIconMapper.kt @@ -0,0 +1,25 @@ +package com.resolum.intiva.features.finances.presentation.transactions.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.filled.Restaurant +import androidx.compose.material.icons.filled.School +import androidx.compose.material.icons.filled.ShoppingBag +import androidx.compose.ui.graphics.vector.ImageVector +import java.util.Locale + +fun iconForTransactionCategory(name: String): ImageVector { + val normalized = name.lowercase(Locale.ROOT) + return when { + "aliment" in normalized || "comida" in normalized -> Icons.Default.Restaurant + "transport" in normalized || "movilidad" in normalized -> Icons.Default.DirectionsCar + "vivienda" in normalized || "hogar" in normalized || "casa" in normalized -> Icons.Default.Home + "salud" in normalized -> Icons.Default.FavoriteBorder + "compra" in normalized -> Icons.Default.ShoppingBag + "educ" in normalized -> Icons.Default.School + else -> Icons.Default.MoreHoriz + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionFiltersSheet.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionFiltersSheet.kt new file mode 100644 index 0000000..a95e7ad --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionFiltersSheet.kt @@ -0,0 +1,480 @@ +package com.resolum.intiva.features.finances.presentation.transactions.components + +import androidx.compose.foundation.BorderStroke +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.foundation.layout.size +import androidx.compose.foundation.layout.width +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.filled.CalendarMonth +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDefaults +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.resolum.intiva.core.ui.theme.IntivaColors +import com.resolum.intiva.features.finances.presentation.transactions.DateRangeOption +import com.resolum.intiva.features.finances.presentation.transactions.FilterCategory +import com.resolum.intiva.features.finances.presentation.transactions.FilterOption +import com.resolum.intiva.features.finances.presentation.transactions.TransactionFilters +import com.resolum.intiva.features.finances.presentation.transactions.formatFilterDate +import com.resolum.intiva.features.finances.presentation.transactions.toDatePickerLocalDate +import com.resolum.intiva.features.finances.presentation.transactions.toDatePickerMillis +import com.resolum.intiva.features.finances.presentation.transactions.toDates +import java.time.LocalDate + +private val SheetBackground = Color.White +private val SoftLavender = Color(0xFFF0EAF7) +private val SelectedLavender = Color(0xFFE8E6FF) +private val PurpleText = Color(0xFF251094) +private val MutedText = Color(0xFF5B5666) +private val SectionText = Color(0xFF817A8D) + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TransactionFiltersSheet( + filters: TransactionFilters, + categories: List, + onFiltersChange: (TransactionFilters) -> Unit, + onClearCategories: () -> Unit, + onClose: () -> Unit, + onApply: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(SheetBackground) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp) + ) { + FilterSheetHeader(onClose = onClose) + + FilterSectionTitle(text = "RANGO DE FECHAS") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + DateRangeOption.entries.forEach { option -> + FilterPill( + text = option.title, + selected = filters.dateRangeOption == option, + onClick = { + val (from, to) = option.toDates() + onFiltersChange( + filters.copy( + dateRangeOption = option, + fromDate = from, + toDate = to + ) + ) + }, + modifier = Modifier.weight(1f), + compact = true + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + DateInput( + label = "Desde", + date = filters.fromDate, + onDateSelected = { + val nextToDate = filters.toDate?.takeIf { toDate -> !toDate.isBefore(it ?: toDate) } + onFiltersChange( + filters.copy( + fromDate = it, + toDate = nextToDate ?: it + ) + ) + }, + modifier = Modifier.weight(1f) + ) + DateInput( + label = "Hasta", + date = filters.toDate, + onDateSelected = { + val nextFromDate = filters.fromDate?.takeIf { fromDate -> !fromDate.isAfter(it ?: fromDate) } + onFiltersChange( + filters.copy( + fromDate = nextFromDate ?: it, + toDate = it + ) + ) + }, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(28.dp)) + FilterSectionTitle(text = "TIPO DE MOVIMIENTO") + MovementSelector( + selected = filters.type, + onSelected = { onFiltersChange(filters.copy(type = it, categoryIds = emptySet())) } + ) + + Spacer(modifier = Modifier.height(28.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + FilterSectionTitle(text = "CATEGORIAS") + + Text( + text = "Borrar selecci贸n", + color = IntivaColors.PrimaryBrand, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.clickable { onClearCategories() } + ) + } + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + categories.forEach { category -> + val selected = category.id in filters.categoryIds + FilterPill( + text = category.name, + selected = selected, + icon = category.icon, + onClick = { + val nextSelection = if (selected) { + filters.categoryIds - category.id + } else { + filters.categoryIds + category.id + } + onFiltersChange(filters.copy(categoryIds = nextSelection)) + } + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + ApplyFiltersButton(onApply = onApply) + } +} + +@Composable +private fun FilterSheetHeader(onClose: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 18.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Filtros", + fontWeight = FontWeight.Bold, + fontSize = 30.sp, + color = IntivaColors.TextPrimary + ) + Box( + modifier = Modifier + .padding(top = 8.dp) + .width(74.dp) + .height(4.dp) + .clip(RoundedCornerShape(10.dp)) + .background(IntivaColors.PrimaryAction) + ) + } + + IconButton( + onClick = onClose, + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(Color(0xFFE9E2F0)) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Cerrar filtros", + tint = Color(0xFF4A4653) + ) + } + } +} + +@Composable +private fun MovementSelector( + selected: FilterOption, + onSelected: (FilterOption) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFFE6DFEB)) + .padding(6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + FilterOption.entries.forEach { option -> + val isSelected = selected == option + + Box( + modifier = Modifier + .weight(1f) + .height(56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (isSelected) Color(0xFFFFFBFE) else Color.Transparent) + .clickable { onSelected(option) }, + contentAlignment = Alignment.Center + ) { + Text( + text = option.title, + color = if (isSelected) IntivaColors.PrimaryBrand else MutedText, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun FilterSectionTitle(text: String) { + Text( + text = text, + color = SectionText, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + letterSpacing = 0.sp, + modifier = Modifier.padding(bottom = 12.dp) + ) +} + +@Composable +private fun FilterPill( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + compact: Boolean = false +) { + val shape = RoundedCornerShape(18.dp) + Row( + modifier = modifier + .height(if (compact) 58.dp else 50.dp) + .clip(shape) + .background(if (selected) SelectedLavender else SoftLavender) + .border( + BorderStroke( + width = if (selected) 1.2.dp else 0.dp, + color = if (selected) IntivaColors.PrimaryBrand else Color.Transparent + ), + shape + ) + .clickable { onClick() } + .padding(horizontal = if (compact) 10.dp else 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + icon?.let { + Icon( + imageVector = it, + contentDescription = null, + tint = if (selected) PurpleText else MutedText, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(5.dp)) + } + Text( + text = text, + color = if (selected) PurpleText else MutedText, + fontSize = if (compact) 14.sp else 15.sp, + lineHeight = if (compact) 17.sp else 18.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = if (compact) 2 else 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DateInput( + label: String, + date: LocalDate?, + onDateSelected: (LocalDate?) -> Unit, + modifier: Modifier = Modifier +) { + var showPicker by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + Text( + text = label, + color = MutedText, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 8.dp) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .height(58.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color(0xFFF5F0FA)) + .border(1.dp, Color(0xFFD9CEE5), RoundedCornerShape(14.dp)) + .clickable { showPicker = true } + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = date?.formatFilterDate() ?: "Seleccionar", + color = if (date == null) Color(0xFF827C8D) else IntivaColors.TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Default.CalendarMonth, + contentDescription = null, + tint = IntivaColors.PrimaryBrand, + modifier = Modifier.size(23.dp) + ) + } + } + + if (showPicker) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = date?.toDatePickerMillis() + ) + val selectedDate = datePickerState.selectedDateMillis + ?.toDatePickerLocalDate() + ?: date + + DatePickerDialog( + onDismissRequest = { showPicker = false }, + confirmButton = { + TextButton( + onClick = { + onDateSelected(datePickerState.selectedDateMillis?.toDatePickerLocalDate()) + showPicker = false + } + ) { + Text("Aceptar", color = IntivaColors.PrimaryBrand) + } + }, + dismissButton = { + TextButton(onClick = { showPicker = false }) { + Text("Cancelar", color = MutedText) + } + } + ) { + DatePicker( + state = datePickerState, + title = { + Text( + text = "Selecciona una fecha", + color = MutedText, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 24.dp, top = 18.dp) + ) + }, + headline = { + Text( + text = selectedDate?.formatFilterDate() ?: "Fecha", + color = IntivaColors.TextPrimary, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 24.dp, bottom = 12.dp) + ) + }, + colors = DatePickerDefaults.colors( + containerColor = Color.White, + selectedDayContainerColor = IntivaColors.PrimaryBrand, + selectedDayContentColor = Color.White, + todayDateBorderColor = IntivaColors.PrimaryAction, + todayContentColor = IntivaColors.PrimaryBrand, + currentYearContentColor = IntivaColors.PrimaryBrand, + selectedYearContainerColor = IntivaColors.PrimaryBrand + ) + ) + } + } +} + +@Composable +private fun ApplyFiltersButton(onApply: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(66.dp) + .clip(RoundedCornerShape(18.dp)) + .background(IntivaColors.PrimaryAction) + .clickable { onApply() }, + contentAlignment = Alignment.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Aplicar Filtros", + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = IntivaColors.TextPrimary, + maxLines = 1 + ) + Spacer(modifier = Modifier.width(12.dp)) + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = IntivaColors.TextPrimary + ) + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionItem.kt b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionItem.kt index c251c32..574482d 100644 --- a/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionItem.kt +++ b/app/src/main/java/com/resolum/intiva/features/finances/presentation/transactions/components/TransactionItem.kt @@ -1,6 +1,7 @@ package com.resolum.intiva.features.finances.presentation.transactions.components import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -37,7 +38,8 @@ import com.resolum.intiva.core.utils.date.toRelativeDateTime */ @Composable fun TransactionItem( - transaction: TransactionWithDesignResponse + transaction: TransactionWithDesignResponse, + onClick: (TransactionWithDesignResponse) -> Unit = {} ) { val isIncome = transaction.transactionType == TransactionType.INCOME.name @@ -54,7 +56,9 @@ fun TransactionItem( Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clickable { onClick(transaction) }, shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = Color.White), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) @@ -112,4 +116,4 @@ private fun formatAmount(amount: String): String { return runCatching { amount.toBigDecimal().setScale(2).toPlainString() }.getOrElse { amount } -} \ No newline at end of file +} From 1ba659682339c6d7a8a10f25db1017d15efe9a3e Mon Sep 17 00:00:00 2001 From: Far14z Date: Thu, 11 Jun 2026 23:15:19 -0500 Subject: [PATCH 15/15] refactor(deployment): simplify app version setting logic in distribution workflow. --- .../workflows/firebase-app-distribution.yml | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/.github/workflows/firebase-app-distribution.yml b/.github/workflows/firebase-app-distribution.yml index 877140a..10424e0 100644 --- a/.github/workflows/firebase-app-distribution.yml +++ b/.github/workflows/firebase-app-distribution.yml @@ -3,11 +3,7 @@ name: Intiva Firebase App Distribution on: push: branches: - - main - 'release/**' - release: - types: - - published workflow_dispatch: jobs: @@ -84,17 +80,7 @@ jobs: - name: Set app version run: | - RAW_VERSION="" - - if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then - RAW_VERSION="${{ github.event.release.tag_name }}" - elif [[ "${GITHUB_REF_NAME}" == release/* ]]; then - RAW_VERSION="${GITHUB_REF_NAME#release/}" - elif [[ "${GITHUB_REF_NAME}" == "main" ]]; then - RAW_VERSION="$(git describe --tags --abbrev=0 2>/dev/null || true)" - else - RAW_VERSION="1.0.0" - fi + RAW_VERSION="${GITHUB_REF_NAME#release/}" if [[ -z "$RAW_VERSION" ]]; then RAW_VERSION="1.0.0" @@ -122,4 +108,4 @@ jobs: KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} - run: ./gradlew appDistributionUploadRelease + run: ./gradlew appDistributionUploadRelease \ No newline at end of file