From dd45c749c68c133ad71330e92b634661634fc480 Mon Sep 17 00:00:00 2001 From: equinox-1092 Date: Tue, 23 Jun 2026 22:19:30 -0500 Subject: [PATCH 1/2] feat(communications): add notifications screen layout and register routing flow. --- .../intiva/core/navigation/graph/MainShell.kt | 11 +- .../core/navigation/routes/NavRoutes.kt | 1 + .../remote/models/InAppNotificationDto.kt | 15 ++ .../models/InAppNotificationResponseDto.kt | 8 ++ .../services/InAppNotificationService.kt | 13 ++ .../InAppNotificationRepositoryImpl.kt | 43 ++++++ .../di/InAppNotificationModule.kt | 27 ++++ .../domain/models/InAppNotification.kt | 20 +++ .../InAppNotificationRepository.kt | 8 ++ .../domain/usecase/GetNotificationsUseCase.kt | 14 ++ .../notifications/InAppNotificationCard.kt | 136 +++++++++++------- .../notifications/InAppNotificationUiState.kt | 50 +++++++ .../InAppNotificationsContent.kt | 39 +++-- .../notifications/InAppNotificationsScreen.kt | 87 +++++++++++ .../InAppNotificationsViewModel.kt | 88 ++++++++++++ .../NotificationSectionHeader.kt | 46 ++++++ .../notifications/NotificationSettingItem.kt | 61 ++++++++ .../NotificationSettingsScreen.kt | 90 ++++++++++++ .../NotificationSettingsUiState.kt | 105 ++++++++++++++ .../NotificationSettingsViewModel.kt | 20 +++ .../finances/presentation/HomeScreen.kt | 3 +- 21 files changed, 819 insertions(+), 66 deletions(-) create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/data/remote/models/InAppNotificationDto.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/data/remote/models/InAppNotificationResponseDto.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/data/remote/services/InAppNotificationService.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/data/repositories/InAppNotificationRepositoryImpl.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/di/InAppNotificationModule.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/domain/models/InAppNotification.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/domain/repositories/InAppNotificationRepository.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/domain/usecase/GetNotificationsUseCase.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationsScreen.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationsViewModel.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSectionHeader.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingItem.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingsScreen.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingsUiState.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingsViewModel.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 d6d39e3..58ddabe 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 @@ -36,7 +36,8 @@ 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.communications.presentation.notifications.NotificationSettingsScreen +import com.resolum.intiva.features.communications.presentation.notifications.InAppNotificationsScreen import com.resolum.intiva.features.profiles.presentation.AparienciaScreen import com.resolum.intiva.features.household.presentation.family.FamilyScreen import com.resolum.intiva.features.household.presentation.invite.InviteMemberScreen @@ -189,7 +190,13 @@ fun MainShell( } composable(NavRoutes.NOTIFICATIONS) { - NotificacionesScreen( + NotificationSettingsScreen( + onNavigateBack = { shellNavController.popBackStack() } + ) + } + + composable(NavRoutes.IN_APP_NOTIFICATIONS) { + InAppNotificationsScreen( onNavigateBack = { shellNavController.popBackStack() } ) } 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 1fef93f..946c46c 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 @@ -26,6 +26,7 @@ object NavRoutes { const val PRIVACY = "privacy" const val HELP = "help" const val NOTIFICATIONS = "notifications" + const val IN_APP_NOTIFICATIONS = "in_app_notifications" const val APPEARANCE = "appearance" const val NEW_INCOME = "transactions/new_income" diff --git a/app/src/main/java/com/resolum/intiva/features/communications/data/remote/models/InAppNotificationDto.kt b/app/src/main/java/com/resolum/intiva/features/communications/data/remote/models/InAppNotificationDto.kt new file mode 100644 index 0000000..6cacea1 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/data/remote/models/InAppNotificationDto.kt @@ -0,0 +1,15 @@ +package com.resolum.intiva.features.communications.data.remote.models + +import com.google.gson.annotations.SerializedName + +data class InAppNotificationDto( + @SerializedName("id") val id: Long, + @SerializedName("recipientUserId") val recipientUserId: Long, + @SerializedName("type") val type: String, + @SerializedName("source") val source: String, + @SerializedName("sourceId") val sourceId: Long, + @SerializedName("title") val title: String, + @SerializedName("message") val message: String, + @SerializedName("status") val status: String, + @SerializedName("createdAt") val createdAt: String +) diff --git a/app/src/main/java/com/resolum/intiva/features/communications/data/remote/models/InAppNotificationResponseDto.kt b/app/src/main/java/com/resolum/intiva/features/communications/data/remote/models/InAppNotificationResponseDto.kt new file mode 100644 index 0000000..74b2c20 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/data/remote/models/InAppNotificationResponseDto.kt @@ -0,0 +1,8 @@ +package com.resolum.intiva.features.communications.data.remote.models + +import com.google.gson.annotations.SerializedName + +data class InAppNotificationResponseDto( + @SerializedName("message") val message: String, + @SerializedName("data") val data: List +) diff --git a/app/src/main/java/com/resolum/intiva/features/communications/data/remote/services/InAppNotificationService.kt b/app/src/main/java/com/resolum/intiva/features/communications/data/remote/services/InAppNotificationService.kt new file mode 100644 index 0000000..155bdfc --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/data/remote/services/InAppNotificationService.kt @@ -0,0 +1,13 @@ +package com.resolum.intiva.features.communications.data.remote.services + +import com.resolum.intiva.features.communications.data.remote.models.InAppNotificationResponseDto +import retrofit2.http.GET +import retrofit2.http.Query + +interface InAppNotificationService { + + @GET("notifications") + suspend fun getNotifications( + @Query("userId") userId: Long + ): InAppNotificationResponseDto +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/data/repositories/InAppNotificationRepositoryImpl.kt b/app/src/main/java/com/resolum/intiva/features/communications/data/repositories/InAppNotificationRepositoryImpl.kt new file mode 100644 index 0000000..ce5d023 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/data/repositories/InAppNotificationRepositoryImpl.kt @@ -0,0 +1,43 @@ +package com.resolum.intiva.features.communications.data.repositories + +import com.resolum.intiva.core.data.repository.BaseRepository +import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.communications.data.remote.models.InAppNotificationDto +import com.resolum.intiva.features.communications.data.remote.services.InAppNotificationService +import com.resolum.intiva.features.communications.domain.models.InAppNotification +import com.resolum.intiva.features.communications.domain.models.InAppNotificationStatus +import com.resolum.intiva.features.communications.domain.repositories.InAppNotificationRepository +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +class InAppNotificationRepositoryImpl @Inject constructor( + private val service: InAppNotificationService +) : BaseRepository(), InAppNotificationRepository { + + override suspend fun getNotifications(userId: Long): NetworkResult> = safeCall { + val response = service.getNotifications(userId) + response.data.map { it.toDomain() } + } + + private fun InAppNotificationDto.toDomain(): InAppNotification { + return InAppNotification( + id = id, + recipientUserId = recipientUserId, + type = type, + source = source, + sourceId = sourceId, + title = title, + message = message, + status = when (status.uppercase()) { + "READ" -> InAppNotificationStatus.READ + else -> InAppNotificationStatus.UNREAD + }, + createdAt = try { + LocalDateTime.parse(createdAt, DateTimeFormatter.ISO_DATE_TIME) + } catch (_: Exception) { + LocalDateTime.now() + } + ) + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/di/InAppNotificationModule.kt b/app/src/main/java/com/resolum/intiva/features/communications/di/InAppNotificationModule.kt new file mode 100644 index 0000000..510db27 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/di/InAppNotificationModule.kt @@ -0,0 +1,27 @@ +package com.resolum.intiva.features.communications.di + +import com.resolum.intiva.features.communications.data.remote.services.InAppNotificationService +import com.resolum.intiva.features.communications.data.repositories.InAppNotificationRepositoryImpl +import com.resolum.intiva.features.communications.domain.repositories.InAppNotificationRepository +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 InAppNotificationModule { + + @Provides + @Singleton + fun provideInAppNotificationService(retrofit: Retrofit): InAppNotificationService = + retrofit.create(InAppNotificationService::class.java) + + @Provides + @Singleton + fun provideInAppNotificationRepository( + impl: InAppNotificationRepositoryImpl + ): InAppNotificationRepository = impl +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/domain/models/InAppNotification.kt b/app/src/main/java/com/resolum/intiva/features/communications/domain/models/InAppNotification.kt new file mode 100644 index 0000000..42c3f34 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/domain/models/InAppNotification.kt @@ -0,0 +1,20 @@ +package com.resolum.intiva.features.communications.domain.models + +import java.time.LocalDateTime + +data class InAppNotification( + val id: Long, + val recipientUserId: Long, + val type: String, + val source: String, + val sourceId: Long, + val title: String, + val message: String, + val status: InAppNotificationStatus, + val createdAt: LocalDateTime +) + +enum class InAppNotificationStatus { + READ, + UNREAD +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/domain/repositories/InAppNotificationRepository.kt b/app/src/main/java/com/resolum/intiva/features/communications/domain/repositories/InAppNotificationRepository.kt new file mode 100644 index 0000000..5be4587 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/domain/repositories/InAppNotificationRepository.kt @@ -0,0 +1,8 @@ +package com.resolum.intiva.features.communications.domain.repositories + +import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.communications.domain.models.InAppNotification + +interface InAppNotificationRepository { + suspend fun getNotifications(userId: Long): NetworkResult> +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/domain/usecase/GetNotificationsUseCase.kt b/app/src/main/java/com/resolum/intiva/features/communications/domain/usecase/GetNotificationsUseCase.kt new file mode 100644 index 0000000..a6e57e8 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/domain/usecase/GetNotificationsUseCase.kt @@ -0,0 +1,14 @@ +package com.resolum.intiva.features.communications.domain.usecase + +import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.communications.domain.models.InAppNotification +import com.resolum.intiva.features.communications.domain.repositories.InAppNotificationRepository +import javax.inject.Inject + +class GetNotificationsUseCase @Inject constructor( + private val repository: InAppNotificationRepository +) { + suspend operator fun invoke(userId: Long): NetworkResult> { + return repository.getNotifications(userId) + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationCard.kt b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationCard.kt index 9fe360d..e75895a 100644 --- a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationCard.kt +++ b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationCard.kt @@ -5,12 +5,15 @@ 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.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -40,6 +43,8 @@ fun InAppNotificationCard( onClick: (InAppNotificationUiState) -> Unit = {} ) { val style = notification.type.visualStyle() + val isUnread = !notification.isRead + val borderColor = if (isUnread) notification.type.borderColor() else Color.Transparent Card( modifier = modifier @@ -49,73 +54,100 @@ fun InAppNotificationCard( colors = CardDefaults.cardColors(containerColor = IntivaColors.SurfaceWhite), elevation = CardDefaults.cardElevation(defaultElevation = 3.dp) ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top - ) { - Box( - modifier = Modifier - .size(42.dp) - .background(style.backgroundColor, CircleShape), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = style.icon, - contentDescription = null, - tint = style.contentColor, - modifier = Modifier.size(22.dp) + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + if (isUnread) { + Box( + modifier = Modifier + .width(4.dp) + .fillMaxHeight() + .background( + color = borderColor, + shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp) + ) ) } - Column(modifier = Modifier.weight(1f)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + Row( + modifier = Modifier + .weight(1f) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .size(42.dp) + .background(style.backgroundColor, CircleShape), + contentAlignment = Alignment.Center ) { - Text( - text = notification.title, - modifier = Modifier.weight(1f), - color = IntivaColors.TextPrimary, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis + Icon( + imageVector = style.icon, + contentDescription = null, + tint = style.contentColor, + modifier = Modifier.size(22.dp) ) - - if (!notification.isRead) { - Box( - modifier = Modifier - .size(8.dp) - .background(IntivaColors.PrimaryBrand, CircleShape) - ) - } } - Spacer(modifier = Modifier.height(4.dp)) + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = notification.title, + modifier = Modifier.weight(1f), + color = IntivaColors.TextPrimary, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) - Text( - text = notification.message, - color = IntivaColors.TextSecondary, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + if (notification.createdAt.isNotEmpty()) { + Text( + text = notification.createdAt, + color = IntivaColors.TextSecondary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium + ) + } + + if (isUnread) { + Box( + modifier = Modifier + .size(8.dp) + .background(IntivaColors.PrimaryBrand, CircleShape) + ) + } + } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) - Text( - text = notification.createdAt, - color = IntivaColors.TextSecondary, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Medium - ) + Text( + text = notification.message, + color = IntivaColors.TextSecondary, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } } } } +private fun InAppNotificationType.borderColor(): Color = when (this) { + InAppNotificationType.Error, + InAppNotificationType.Urgent -> IntivaColors.StatusError + + InAppNotificationType.Info, + InAppNotificationType.Remembering -> IntivaColors.StatusInfo + + InAppNotificationType.Warning -> IntivaColors.StatusWarning + InAppNotificationType.Success -> IntivaColors.StatusSuccess +} + private data class NotificationVisualStyle( val icon: ImageVector, val backgroundColor: Color, diff --git a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationUiState.kt b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationUiState.kt index daa002d..2821c32 100644 --- a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationUiState.kt +++ b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationUiState.kt @@ -1,5 +1,20 @@ package com.resolum.intiva.features.communications.presentation.notifications +import com.resolum.intiva.features.communications.domain.models.InAppNotification +import com.resolum.intiva.features.communications.domain.models.InAppNotificationStatus +import java.time.Duration +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale + +sealed class InAppNotificationsScreenState { + data object Loading : InAppNotificationsScreenState() + data class Success( + val groupedNotifications: Map> + ) : InAppNotificationsScreenState() + data class Error(val message: String) : InAppNotificationsScreenState() +} + data class InAppNotificationUiState( val id: Long, val title: String, @@ -17,3 +32,38 @@ enum class InAppNotificationType { Urgent, Remembering } + +fun InAppNotification.toNotificationType(): InAppNotificationType = when (type.lowercase()) { + "info" -> InAppNotificationType.Info + "warning" -> InAppNotificationType.Warning + "success" -> InAppNotificationType.Success + "error" -> InAppNotificationType.Error + "urgent" -> InAppNotificationType.Urgent + "remembering" -> InAppNotificationType.Remembering + else -> InAppNotificationType.Info +} + +fun InAppNotification.toUiState(): InAppNotificationUiState = InAppNotificationUiState( + id = id, + title = title, + message = message, + type = toNotificationType(), + createdAt = formatRelativeTime(createdAt), + isRead = status == InAppNotificationStatus.READ +) + +private fun formatRelativeTime(createdAt: LocalDateTime): String { + val now = LocalDateTime.now() + val duration = Duration.between(createdAt, now) + return when { + duration.toMinutes() < 1 -> "Ahora" + duration.toMinutes() < 60 -> "Hace ${duration.toMinutes()} min" + duration.toHours() < 24 -> "Hace ${duration.toHours()} h" + duration.toDays() == 1L -> "Ayer" + duration.toDays() < 7 -> "Hace ${duration.toDays()} días" + else -> { + val formatter = DateTimeFormatter.ofPattern("d MMM", Locale("es")) + createdAt.format(formatter) + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationsContent.kt b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationsContent.kt index 811f0db..d5eab57 100644 --- a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationsContent.kt +++ b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationsContent.kt @@ -1,10 +1,12 @@ package com.resolum.intiva.features.communications.presentation.notifications +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.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -23,28 +25,43 @@ import com.resolum.intiva.core.ui.theme.IntivaColors @Composable fun InAppNotificationsContent( - notifications: List, + groupedNotifications: Map>, modifier: Modifier = Modifier, onNotificationClick: (InAppNotificationUiState) -> Unit = {} ) { - if (notifications.isEmpty()) { + if (groupedNotifications.isEmpty()) { EmptyNotificationsContent(modifier = modifier) return } LazyColumn( modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(20.dp), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items( - items = notifications, - key = { it.id } - ) { notification -> - InAppNotificationCard( - notification = notification, - onClick = onNotificationClick - ) + groupedNotifications.forEach { (dateGroup, notifications) -> + stickyHeader { + Text( + text = dateGroup, + modifier = Modifier + .fillMaxWidth() + .background(IntivaColors.BackgroundSurface) + .padding(vertical = 8.dp), + color = IntivaColors.TextSecondary, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold + ) + } + + items( + items = notifications, + key = { it.id } + ) { notification -> + InAppNotificationCard( + notification = notification, + onClick = onNotificationClick + ) + } } } } diff --git a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationsScreen.kt b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationsScreen.kt new file mode 100644 index 0000000..42968b1 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationsScreen.kt @@ -0,0 +1,87 @@ +package com.resolum.intiva.features.communications.presentation.notifications + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.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.text.font.FontWeight +import androidx.hilt.navigation.compose.hiltViewModel +import com.resolum.intiva.core.ui.theme.IntivaColors + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InAppNotificationsScreen( + onNavigateBack: () -> Unit, + viewModel: InAppNotificationsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Notificaciones", fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Volver" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.White + ) + ) + }, + containerColor = Color.White + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + when (val state = uiState) { + is InAppNotificationsScreenState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = IntivaColors.IconPurple) + } + } + is InAppNotificationsScreenState.Success -> { + InAppNotificationsContent( + groupedNotifications = state.groupedNotifications, + onNotificationClick = { /* TODO: handle navigation to detail */ } + ) + } + is InAppNotificationsScreenState.Error -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = state.message, + color = IntivaColors.StatusError + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationsViewModel.kt b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationsViewModel.kt new file mode 100644 index 0000000..3323ec3 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/InAppNotificationsViewModel.kt @@ -0,0 +1,88 @@ +package com.resolum.intiva.features.communications.presentation.notifications + +import com.resolum.intiva.core.common.viewmodel.BaseViewModel +import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.communications.domain.models.InAppNotification +import com.resolum.intiva.features.communications.domain.usecase.GetNotificationsUseCase +import com.resolum.intiva.features.iam.domain.repositories.SessionRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class InAppNotificationsViewModel @Inject constructor( + private val getNotificationsUseCase: GetNotificationsUseCase, + private val sessionRepository: SessionRepository +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(InAppNotificationsScreenState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadNotifications() + } + + fun refresh() { + loadNotifications() + } + + private fun loadNotifications() { + safeLaunch { + _uiState.value = InAppNotificationsScreenState.Loading + + val userId = sessionRepository.getUserId() + if (userId == null) { + _uiState.value = InAppNotificationsScreenState.Error( + message = "No se pudo obtener el usuario de la sesión." + ) + return@safeLaunch + } + + when (val result = getNotificationsUseCase(userId)) { + is NetworkResult.Success -> { + val grouped = groupByDate(result.data) + _uiState.value = InAppNotificationsScreenState.Success( + groupedNotifications = grouped + ) + } + is NetworkResult.Error -> { + _uiState.value = InAppNotificationsScreenState.Error( + message = result.message + ) + } + } + } + } + + private fun groupByDate(notifications: List): Map> { + val today = LocalDate.now() + val yesterday = today.minusDays(1) + val dateFormatter = DateTimeFormatter.ofPattern("d MMM", Locale("es")) + + val sorted = notifications.sortedByDescending { it.createdAt } + + val groupedDomain = sorted.groupBy { notification -> + val date = notification.createdAt.toLocalDate() + when { + date == today -> "Hoy" + date == yesterday -> "Ayer" + else -> date.format(dateFormatter) + } + } + + return groupedDomain.mapValues { (_, notifications) -> + notifications.map { it.toUiState() } + } + } + + override fun handleError(throwable: Throwable) { + _uiState.value = InAppNotificationsScreenState.Error( + message = throwable.message ?: "Error inesperado" + ) + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSectionHeader.kt b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSectionHeader.kt new file mode 100644 index 0000000..071b150 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSectionHeader.kt @@ -0,0 +1,46 @@ +package com.resolum.intiva.features.communications.presentation.notifications + +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.layout.size +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.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.resolum.intiva.core.ui.theme.IntivaColors + +@Composable +fun NotificationSectionHeader( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = 24.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = IntivaColors.PrimaryBrand, + modifier = Modifier.size(20.dp) + ) + + Text( + text = title, + color = IntivaColors.PrimaryBrand, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold + ) + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingItem.kt b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingItem.kt new file mode 100644 index 0000000..42ded8a --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingItem.kt @@ -0,0 +1,61 @@ +package com.resolum.intiva.features.communications.presentation.notifications + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +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 com.resolum.intiva.core.ui.theme.IntivaColors + +@Composable +fun NotificationSettingItem( + item: NotificationSettingDisplayItem, + onToggle: (itemId: String, enabled: Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = IntivaColors.TextPrimary + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = item.subtitle, + style = MaterialTheme.typography.bodySmall, + color = IntivaColors.TextSecondary + ) + } + + Switch( + checked = item.enabled, + onCheckedChange = { onToggle(item.id, it) }, + colors = SwitchDefaults.colors( + checkedTrackColor = IntivaColors.PrimaryBrand, + checkedThumbColor = Color.White, + uncheckedTrackColor = IntivaColors.TextSecondary.copy(alpha = 0.3f), + uncheckedThumbColor = Color.White + ) + ) + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingsScreen.kt b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingsScreen.kt new file mode 100644 index 0000000..c7d3071 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingsScreen.kt @@ -0,0 +1,90 @@ +package com.resolum.intiva.features.communications.presentation.notifications + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +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.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.resolum.intiva.core.ui.theme.IntivaColors + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationSettingsScreen( + onNavigateBack: () -> Unit, + viewModel: NotificationSettingsViewModel = androidx.lifecycle.viewmodel.compose.viewModel() +) { + val sections by viewModel.sections.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Notificaciones") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "Volver" + ) + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(horizontal = 20.dp) + ) { + item(key = "subtitle") { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Personaliza cómo y cuándo Intiva se comunica contigo para mantenerte informado.", + style = MaterialTheme.typography.bodyMedium, + color = IntivaColors.TextSecondary, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + sections.forEach { section -> + item(key = "header_${section.title}") { + NotificationSectionHeader( + icon = section.icon, + title = section.title + ) + } + + items( + items = section.items, + key = { "setting_${it.id}" } + ) { item -> + NotificationSettingItem( + item = item, + onToggle = viewModel::onToggle + ) + } + } + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingsUiState.kt b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingsUiState.kt new file mode 100644 index 0000000..178064c --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingsUiState.kt @@ -0,0 +1,105 @@ +package com.resolum.intiva.features.communications.presentation.notifications + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBalance +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Lock +import androidx.compose.ui.graphics.vector.ImageVector + +data class NotificationSection( + val title: String, + val icon: ImageVector, + val items: List +) + +data class NotificationSettingDisplayItem( + val id: String, + val title: String, + val subtitle: String, + val enabled: Boolean +) + +data class SectionInfo( + val title: String, + val icon: ImageVector +) + +val SECTIONS = listOf( + SectionInfo(title = "INFORMACIÓN Y ACTUALIZACIONES", icon = Icons.Default.Info), + SectionInfo(title = "ACTIVIDAD FINANCIERA", icon = Icons.Default.AccountBalance), + SectionInfo(title = "SEGURIDAD", icon = Icons.Default.Lock), + SectionInfo(title = "ACTIVIDAD FAMILIAR", icon = Icons.Default.Groups) +) + +val DEFAULT_SETTINGS = listOf( + NotificationSettingDisplayItem( + id = "APP_UPDATES", title = "Novedades de la aplicación", + subtitle = "Nuevas funciones, mejoras y consejos para aprovechar al máximo tu cuenta.", + enabled = true + ), + NotificationSettingDisplayItem( + id = "PROMOTIONS", title = "Ofertas y Promociones", + subtitle = "Beneficios exclusivos, tasas preferenciales y recompensas para tu familia.", + enabled = false + ), + NotificationSettingDisplayItem( + id = "TRANSFERS", title = "Transferencias e Ingresos", + subtitle = "Alertas instantáneas cuando recibas dinero o depósitos en tu cuenta principal.", + enabled = true + ), + NotificationSettingDisplayItem( + id = "SCHEDULED_PAYMENTS", title = "Pagos Programados", + subtitle = "Recordatorios antes de que se ejecuten cobros recurrentes o suscripciones.", + enabled = true + ), + NotificationSettingDisplayItem( + id = "LOW_BALANCE", title = "Alerta de Saldo Bajo", + subtitle = "Te avisaremos si tu saldo principal cae por debajo de S/. 100.00.", + enabled = true + ), + NotificationSettingDisplayItem( + id = "NEW_LOGINS", title = "Nuevos Inicios de Sesión", + subtitle = "Alertas obligatorias cuando detectamos acceso desde un dispositivo nuevo.", + enabled = true + ), + NotificationSettingDisplayItem( + id = "ACCOUNT_CHANGES", title = "Cambios en la Cuenta", + subtitle = "Notificaciones por actualizaciones de contraseña, PIN o datos personales.", + enabled = true + ), + NotificationSettingDisplayItem( + id = "APPROVAL_REQUESTS", title = "Solicitudes de Aprobación", + subtitle = "Avisos cuando un dependiente solicita fondos o permiso para un gasto.", + enabled = true + ), + NotificationSettingDisplayItem( + id = "GOAL_PROGRESS", title = "Progreso de Metas", + subtitle = "Celebra cuando tu familia alcance hitos de ahorro compartidos.", + enabled = true + ) +) + +fun buildDefaultSections(): List { + val typeToSection = mapOf( + "APP_UPDATES" to 0, "PROMOTIONS" to 0, + "TRANSFERS" to 1, "SCHEDULED_PAYMENTS" to 1, "LOW_BALANCE" to 1, + "NEW_LOGINS" to 2, "ACCOUNT_CHANGES" to 2, + "APPROVAL_REQUESTS" to 3, "GOAL_PROGRESS" to 3 + ) + + val grouped = MutableList(SECTIONS.size) { mutableListOf() } + + DEFAULT_SETTINGS.forEach { item -> + val sectionIndex = typeToSection[item.id] ?: return@forEach + grouped[sectionIndex].add(item) + } + + return SECTIONS.mapIndexed { index, section -> + NotificationSection( + title = section.title, + icon = section.icon, + items = grouped[index] + ) + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingsViewModel.kt b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingsViewModel.kt new file mode 100644 index 0000000..9138f4b --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/communications/presentation/notifications/NotificationSettingsViewModel.kt @@ -0,0 +1,20 @@ +package com.resolum.intiva.features.communications.presentation.notifications + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class NotificationSettingsViewModel : ViewModel() { + + private val _sections = MutableStateFlow(buildDefaultSections()) + val sections: StateFlow> = _sections.asStateFlow() + + fun onToggle(itemId: String, enabled: Boolean) { + _sections.value = _sections.value.map { section -> + section.copy(items = section.items.map { item -> + if (item.id == itemId) item.copy(enabled = enabled) else item + }) + } + } +} 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 d32fcb7..00ac66c 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 @@ -58,6 +58,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController +import com.resolum.intiva.core.navigation.routes.NavRoutes import com.resolum.intiva.core.common.state.UiState import com.resolum.intiva.core.ui.snackbar.IntivaSnackBarHost import com.resolum.intiva.core.ui.snackbar.SnackBarType @@ -180,7 +181,7 @@ fun HomeScreen( } }, actions = { - IconButton(onClick = { }) { + IconButton(onClick = { navController.navigate(NavRoutes.IN_APP_NOTIFICATIONS) }) { Icon( Icons.Default.NotificationsNone, contentDescription = "Notifications", From 1f1da8d954aab82c7785f5c2ff01924bb7fff3d9 Mon Sep 17 00:00:00 2001 From: equinox-1092 Date: Wed, 24 Jun 2026 00:02:24 -0500 Subject: [PATCH 2/2] feat(finances): implement edit account flow and fetch real transactions in detail screen. --- .../core/data/local/room/AppDatabase.kt | 2 +- .../intiva/core/navigation/graph/MainShell.kt | 24 + .../core/navigation/routes/NavRoutes.kt | 5 + .../local/entities/FinancialAccountEntity.kt | 3 +- .../mappers/FinancialAccountEntityMapper.kt | 9 +- .../remote/FinancialAccountFacadeService.kt | 30 +- .../remote/mappers/FinancialAccountMapper.kt | 3 +- .../CreateFinancialAccountRequestDto.kt | 16 +- .../models/FinancialAccountResponseDto.kt | 3 +- .../UpdateFinancialAccountRequestDto.kt | 8 + .../services/FinancialAccountService.kt | 32 +- .../FinancialAccountRepositoryImpl.kt | 39 +- .../domain/models/FinancialAccount.kt | 15 +- .../FinancialAccountRepository.kt | 14 +- .../DisableFinancialAccountUseCase.kt | 4 +- .../usecases/GetFinancesAccountUseCase.kt | 3 + .../usecases/UpdateFinancialAccountUseCase.kt | 18 + .../financialaccount/AccountDetailScreen.kt | 550 +++++++++++++ .../financialaccount/AccountDetailUiState.kt | 12 + .../AccountDetailViewModel.kt | 98 +++ .../CreateFinancialAccountScreen.kt | 752 +++++++----------- .../EditFinancialAccountScreen.kt | 314 ++++++++ .../EditFinancialAccountViewModel.kt | 75 ++ .../FinancialAccountScreen.kt | 369 ++++++--- .../FinancialAccountViewModel.kt | 28 +- .../components/FinancialAccountCard.kt | 28 +- 26 files changed, 1765 insertions(+), 689 deletions(-) create mode 100644 app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/UpdateFinancialAccountRequestDto.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/usecases/UpdateFinancialAccountUseCase.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/AccountDetailScreen.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/AccountDetailUiState.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/AccountDetailViewModel.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/EditFinancialAccountScreen.kt create mode 100644 app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/EditFinancialAccountViewModel.kt diff --git a/app/src/main/java/com/resolum/intiva/core/data/local/room/AppDatabase.kt b/app/src/main/java/com/resolum/intiva/core/data/local/room/AppDatabase.kt index afcfb4d..977fe67 100644 --- a/app/src/main/java/com/resolum/intiva/core/data/local/room/AppDatabase.kt +++ b/app/src/main/java/com/resolum/intiva/core/data/local/room/AppDatabase.kt @@ -35,7 +35,7 @@ import com.resolum.intiva.features.paymentmethodsandcategories.data.local.entiti CategoryEntity::class, FinancialAccountEntity::class ], - version = 4, + version = 5, exportSchema = false ) @TypeConverters(RoomConverters::class) 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 58ddabe..0e7045d 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 @@ -21,8 +21,10 @@ import com.resolum.intiva.features.finances.presentation.transactions.Transactio 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.AccountDetailScreen import com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount.CreateFinancialAccountScreen import com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount.FinancialAccountScreen +import com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount.EditFinancialAccountScreen import com.resolum.intiva.features.savings.presentation.SavingsGoalCreateScreen import com.resolum.intiva.features.savings.presentation.SavingsGoalDetailScreen import com.resolum.intiva.features.savings.presentation.SavingsGoalEditScreen @@ -171,6 +173,9 @@ fun MainShell( onNavigateToAppearance = { shellNavController.navigate(NavRoutes.APPEARANCE) }, + onNavigateToLinkedAccounts = { + shellNavController.navigate(NavRoutes.FINANCIAL_ACCOUNTS) + }, onNavigateBack = { shellNavController.popBackStack() } @@ -218,6 +223,9 @@ fun MainShell( FinancialAccountScreen( onAddAccountClick = { shellNavController.navigate(NavRoutes.CREATE_FINANCIAL_ACCOUNT) + }, + onAccountClick = { accountId -> + shellNavController.navigate(NavRoutes.financialAccountDetail(accountId)) } ) } @@ -229,6 +237,22 @@ fun MainShell( ) } + composable(NavRoutes.FINANCIAL_ACCOUNT_DETAIL) { + AccountDetailScreen( + onBackClick = { shellNavController.popBackStack() }, + onEditClick = { accountId -> + shellNavController.navigate(NavRoutes.editFinancialAccount(accountId)) + } + ) + } + + composable(NavRoutes.EDIT_FINANCIAL_ACCOUNT) { + EditFinancialAccountScreen( + onAccountUpdated = { shellNavController.popBackStack() }, + onBackClick = { shellNavController.popBackStack() } + ) + } + /** * Savings Goals feature navigation. */ 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 946c46c..977ee1f 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 @@ -38,6 +38,11 @@ object NavRoutes { const val FINANCIAL_ACCOUNTS = "financial_accounts" const val CREATE_FINANCIAL_ACCOUNT = "create_financial_account" + const val FINANCIAL_ACCOUNT_DETAIL = "financial_account_detail/{accountId}" + const val EDIT_FINANCIAL_ACCOUNT = "edit_financial_account/{accountId}" + + fun financialAccountDetail(accountId: Long) = "financial_account_detail/$accountId" + fun editFinancialAccount(accountId: Long) = "edit_financial_account/$accountId" val BOTTOM_NAV_ROUTES = setOf(HOME, TRANSACTIONS, SAVINGS_GOALS, FAMILY, PROFILE) } diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/local/entities/FinancialAccountEntity.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/local/entities/FinancialAccountEntity.kt index 42378e3..5519eb5 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/local/entities/FinancialAccountEntity.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/local/entities/FinancialAccountEntity.kt @@ -21,5 +21,6 @@ data class FinancialAccountEntity( val currentAmount: Double, val institution: String?, val creditLimit: Double?, - val isActive: Boolean + val isActive: Boolean, + val version: Long = 0 ) diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/local/mappers/FinancialAccountEntityMapper.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/local/mappers/FinancialAccountEntityMapper.kt index c3f43c6..28c2773 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/local/mappers/FinancialAccountEntityMapper.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/local/mappers/FinancialAccountEntityMapper.kt @@ -14,7 +14,8 @@ fun FinancialAccountResponseDto.toEntity(userId: Long): FinancialAccountEntity { currentAmount = currentAmount, institution = institution, creditLimit = creditLimit, - isActive = isActive ?: status.toIsActive() + isActive = isActive ?: status.toIsActive(), + version = version ?: 0 ) } @@ -28,7 +29,8 @@ fun FinancialAccount.toEntity(userId: Long): FinancialAccountEntity { currentAmount = currentAmount, institution = institution, creditLimit = creditLimit, - isActive = isActive + isActive = isActive, + version = version ) } @@ -41,7 +43,8 @@ fun FinancialAccountEntity.toDomain(): FinancialAccount { currentAmount = currentAmount, institution = institution, creditLimit = creditLimit, - isActive = isActive + isActive = isActive, + version = version ) } diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/FinancialAccountFacadeService.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/FinancialAccountFacadeService.kt index aa1808d..fff3416 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/FinancialAccountFacadeService.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/FinancialAccountFacadeService.kt @@ -1,26 +1,15 @@ package com.resolum.intiva.features.paymentmethodsandcategories.data.remote import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.models.CreateFinancialAccountRequestDto +import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.models.UpdateFinancialAccountRequestDto import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.services.FinancialAccountService import javax.inject.Inject -/** - * Facade service for financial account-related operations. - * - * This service abstracts the underlying financial account data retrieval mechanisms and provides a simple interface - * for the rest of the application to interact with. It handles fetching financial accounts based on user ID by - * delegating to the appropriate services. - */ class FinancialAccountFacadeService @Inject constructor( private val financialAccountService: FinancialAccountService ) { - /** - * Retrieves a list of financial accounts associated with the specified [userId]. - * - * @param userId The ID of the user for whom to fetch financial accounts. - * @return A list of financial accounts associated with the specified user ID. - */ - suspend fun getFinancialAccountsByUserId(userId: Long) = financialAccountService.getFinancialAccountsByUserId(userId) + suspend fun getFinancialAccountsByUserId(userId: Long) = + financialAccountService.getFinancialAccountsByUserId(userId) suspend fun createFinancialAccount( userId: Long, @@ -29,11 +18,14 @@ class FinancialAccountFacadeService @Inject constructor( userId = userId, request = request ) - suspend fun disableFinancialAccount( + + suspend fun updateFinancialAccount( userId: Long, - accountId: Long - ) = financialAccountService.disableFinancialAccount( + accountId: Long, + request: UpdateFinancialAccountRequestDto + ) = financialAccountService.updateFinancialAccount( userId = userId, - accountId = accountId + accountId = accountId, + request = request ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/mappers/FinancialAccountMapper.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/mappers/FinancialAccountMapper.kt index 70f487f..374c347 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/mappers/FinancialAccountMapper.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/mappers/FinancialAccountMapper.kt @@ -17,7 +17,8 @@ fun FinancialAccountResponseDto.toDomain() = FinancialAccount( currentAmount = currentAmount, institution = institution, creditLimit = creditLimit, - isActive = isActive ?: status.toIsActive() + isActive = isActive ?: status.toIsActive(), + version = version ?: 0 ) private fun String?.toIsActive(): Boolean { diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/CreateFinancialAccountRequestDto.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/CreateFinancialAccountRequestDto.kt index e04041f..8428ac3 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/CreateFinancialAccountRequestDto.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/CreateFinancialAccountRequestDto.kt @@ -1,11 +1,13 @@ package com.resolum.intiva.features.paymentmethodsandcategories.data.remote.models +import com.google.gson.annotations.SerializedName + data class CreateFinancialAccountRequestDto( - val name: String, - val accountType: String, - val currencyCode: String, - val currentAmount: Double, - val institution: String?, - val creditLimit: Double?, - val isActive: Boolean = true + @SerializedName("name") val name: String, + @SerializedName("accountType") val accountType: String, + @SerializedName("currencyCode") val currencyCode: String, + @SerializedName("initialAmount") val currentAmount: Double, + @SerializedName("institution") val institution: String?, + @SerializedName("creditLimit") val creditLimit: Double?, + @SerializedName("isActive") val isActive: Boolean = true ) \ No newline at end of file diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/FinancialAccountResponseDto.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/FinancialAccountResponseDto.kt index 3417535..2e19525 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/FinancialAccountResponseDto.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/FinancialAccountResponseDto.kt @@ -18,5 +18,6 @@ data class FinancialAccountResponseDto( @SerializedName("institution") val institution: String?, @SerializedName("creditLimit") val creditLimit: Double?, @SerializedName(value = "isActive", alternate = ["active", "enabled"]) val isActive: Boolean? = null, - @SerializedName("status") val status: String? = null + @SerializedName("status") val status: String? = null, + @SerializedName("version") val version: Long? = null ) diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/UpdateFinancialAccountRequestDto.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/UpdateFinancialAccountRequestDto.kt new file mode 100644 index 0000000..9bde97a --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/models/UpdateFinancialAccountRequestDto.kt @@ -0,0 +1,8 @@ +package com.resolum.intiva.features.paymentmethodsandcategories.data.remote.models + +import com.google.gson.annotations.SerializedName + +data class UpdateFinancialAccountRequestDto( + @SerializedName("name") val name: String? = null, + @SerializedName("isActive") val isActive: Boolean? = null +) diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/services/FinancialAccountService.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/services/FinancialAccountService.kt index c44b9f4..1d7fa4d 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/services/FinancialAccountService.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/remote/services/FinancialAccountService.kt @@ -1,31 +1,20 @@ package com.resolum.intiva.features.paymentmethodsandcategories.data.remote.services -import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.models.FinancialAccountResponseDto import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.models.CreateFinancialAccountRequestDto -import retrofit2.http.GET -import retrofit2.http.Path +import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.models.FinancialAccountResponseDto +import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.models.UpdateFinancialAccountRequestDto import retrofit2.http.Body -import retrofit2.http.POST +import retrofit2.http.GET import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path -/** - * Retrofit service interface for fetching financial account data from the API. - * - * This interface defines the endpoints and HTTP methods for interacting with financial account-related - * resources on the server. It includes a method to retrieve financial accounts based on a user's ID. - */ interface FinancialAccountService { - /** - * Fetches a list of financial accounts associated with a specific user ID. - * - * @param userId The ID of the user whose financial accounts are to be retrieved. - * @return A list of [FinancialAccountResponseDto] objects representing the user's financial accounts. - */ @GET("users/{userId}/financial-accounts") suspend fun getFinancialAccountsByUserId( @Path("userId") userId: Long - ) : List + ): List @POST("users/{userId}/financial-accounts") suspend fun createFinancialAccount( @@ -33,9 +22,10 @@ interface FinancialAccountService { @Body request: CreateFinancialAccountRequestDto ): FinancialAccountResponseDto - @PATCH("users/{userId}/financial-accounts/{accountId}/disable") - suspend fun disableFinancialAccount( + @PATCH("users/{userId}/financial-accounts/{accountId}") + suspend fun updateFinancialAccount( @Path("userId") userId: Long, - @Path("accountId") accountId: Long + @Path("accountId") accountId: Long, + @Body request: UpdateFinancialAccountRequestDto ): FinancialAccountResponseDto -} \ No newline at end of file +} diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/repositories/FinancialAccountRepositoryImpl.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/repositories/FinancialAccountRepositoryImpl.kt index 1f96e59..dc188e5 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/repositories/FinancialAccountRepositoryImpl.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/data/repositories/FinancialAccountRepositoryImpl.kt @@ -9,33 +9,17 @@ import com.resolum.intiva.features.paymentmethodsandcategories.data.local.mapper import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.FinancialAccountFacadeService import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.mappers.toDomain import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.models.CreateFinancialAccountRequestDto +import com.resolum.intiva.features.paymentmethodsandcategories.data.remote.models.UpdateFinancialAccountRequestDto import com.resolum.intiva.features.paymentmethodsandcategories.domain.models.FinancialAccount import com.resolum.intiva.features.paymentmethodsandcategories.domain.repositories.FinancialAccountRepository import javax.inject.Inject -/** - * Implementation of the [FinancialAccountRepository] interface that interacts with the [FinancialAccountFacadeService] - * to fetch financial account data from the remote API. It also uses the [SessionRepository] to retrieve - * the current user's session information, such as the user ID, which is required for fetching - * financial accounts specific to the user. - * - * This repository handles the business logic for fetching financial accounts and converting them into - * domain models that can be used by the rest of the application. It also manages error handling - * and ensures that network calls are made safely using the base repository's functionality. - */ class FinancialAccountRepositoryImpl @Inject constructor( private val financialAccountFacadeService: FinancialAccountFacadeService, private val sessionRepository: SessionRepository, private val financialAccountDao: FinancialAccountDao ) : BaseRepository(), FinancialAccountRepository { - /** - * Fetches the list of financial accounts associated with the current user. It retrieves the user ID - * from the session repository and then calls the financial account facade service to get the accounts. - * The result is mapped to a list of domain models and returned as a [NetworkResult]. - * - * @return A [NetworkResult] containing a list of [FinancialAccount] objects on success, or an error message on failure. - */ override suspend fun getFinancialAccountsByUserId(): NetworkResult> { val userId = sessionRepository.getUserId() ?: return NetworkResult.Error("User ID not found in session") @@ -71,6 +55,10 @@ class FinancialAccountRepositoryImpl @Inject constructor( } } + override suspend fun getFinancialAccountById(accountId: Long): FinancialAccount? { + val userId = sessionRepository.getUserId() ?: return null + return financialAccountDao.getById(accountId)?.toDomain() + } override suspend fun createFinancialAccount( name: String, @@ -106,13 +94,26 @@ class FinancialAccountRepositoryImpl @Inject constructor( } } - override suspend fun disableFinancialAccount(accountId: Long): NetworkResult { + override suspend fun updateFinancialAccount( + accountId: Long, + name: String?, + isActive: Boolean? + ): NetworkResult { val userId = sessionRepository.getUserId() ?: return NetworkResult.Error("User ID not found in session") return safeCall { + val request = UpdateFinancialAccountRequestDto( + name = name, + isActive = isActive + ) + val financialAccount = financialAccountFacadeService - .disableFinancialAccount(userId = userId, accountId = accountId) + .updateFinancialAccount( + userId = userId, + accountId = accountId, + request = request + ) .toDomain() financialAccountDao.insert(financialAccount.toEntity(userId)) diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/models/FinancialAccount.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/models/FinancialAccount.kt index 35b95e6..171b3cd 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/models/FinancialAccount.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/models/FinancialAccount.kt @@ -1,17 +1,5 @@ package com.resolum.intiva.features.paymentmethodsandcategories.domain.models -/** - * Data class representing a financial account, which can be associated with payment methods and categories. - * - * @property id Unique identifier for the financial account. - * @property name Name of the financial account. - * @property accountType Type of the financial account (e.g., "checking", "savings", "credit"). - * @property currencyCode ISO currency code associated with the financial account (e.g., "USD", "EUR"). - * @property currentAmount Current balance or amount in the financial account. - * @property institution Optional name of the financial institution associated with the account. - * @property creditLimit Optional credit limit for credit accounts, if applicable. - * @property isActive Indicates whether the financial account is active or not. - */ data class FinancialAccount( val id: Long, val name: String, @@ -20,5 +8,6 @@ data class FinancialAccount( val currentAmount: Double, val institution: String?, val creditLimit: Double?, - val isActive: Boolean + val isActive: Boolean, + val version: Long = 0 ) diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/repositories/FinancialAccountRepository.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/repositories/FinancialAccountRepository.kt index 5c64aea..5f44847 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/repositories/FinancialAccountRepository.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/repositories/FinancialAccountRepository.kt @@ -3,15 +3,10 @@ package com.resolum.intiva.features.paymentmethodsandcategories.domain.repositor import com.resolum.intiva.core.network.model.NetworkResult import com.resolum.intiva.features.paymentmethodsandcategories.domain.models.FinancialAccount -/** - * Repository interface for managing financial accounts, providing methods to fetch financial accounts associated with the user. - * - * This interface abstracts the data source for financial accounts, allowing for different implementations (e.g., remote API, local database) - * without affecting the rest of the application. It defines a method to retrieve financial accounts based on the user's ID. - */ interface FinancialAccountRepository { suspend fun getFinancialAccountsByUserId(): NetworkResult> + suspend fun getFinancialAccountById(accountId: Long): FinancialAccount? suspend fun createFinancialAccount( name: String, @@ -22,6 +17,9 @@ interface FinancialAccountRepository { creditLimit: Double? ): NetworkResult - suspend fun disableFinancialAccount(accountId: Long): NetworkResult - + suspend fun updateFinancialAccount( + accountId: Long, + name: String? = null, + isActive: Boolean? = null + ): NetworkResult } diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/usecases/DisableFinancialAccountUseCase.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/usecases/DisableFinancialAccountUseCase.kt index 901f181..c95e55b 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/usecases/DisableFinancialAccountUseCase.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/usecases/DisableFinancialAccountUseCase.kt @@ -11,8 +11,8 @@ import javax.inject.Inject * @property financialAccountRepository Repository for financial account operations. */ class DisableFinancialAccountUseCase @Inject constructor( - private val financialAccountRepository: FinancialAccountRepository + private val repository: FinancialAccountRepository ) { suspend operator fun invoke(accountId: Long): NetworkResult = - financialAccountRepository.disableFinancialAccount(accountId) + repository.updateFinancialAccount(accountId = accountId, isActive = false) } \ No newline at end of file diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/usecases/GetFinancesAccountUseCase.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/usecases/GetFinancesAccountUseCase.kt index ba7094e..91cbc4a 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/usecases/GetFinancesAccountUseCase.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/usecases/GetFinancesAccountUseCase.kt @@ -17,4 +17,7 @@ class GetFinancesAccountUseCase @Inject constructor( ) { suspend operator fun invoke(): NetworkResult> = repository.getFinancialAccountsByUserId() + + suspend fun getById(accountId: Long): FinancialAccount? = + repository.getFinancialAccountById(accountId) } \ No newline at end of file diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/usecases/UpdateFinancialAccountUseCase.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/usecases/UpdateFinancialAccountUseCase.kt new file mode 100644 index 0000000..4ea31b6 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/domain/usecases/UpdateFinancialAccountUseCase.kt @@ -0,0 +1,18 @@ +package com.resolum.intiva.features.paymentmethodsandcategories.domain.usecases + +import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.paymentmethodsandcategories.domain.models.FinancialAccount +import com.resolum.intiva.features.paymentmethodsandcategories.domain.repositories.FinancialAccountRepository +import javax.inject.Inject + +class UpdateFinancialAccountUseCase @Inject constructor( + private val repository: FinancialAccountRepository +) { + suspend operator fun invoke( + accountId: Long, + name: String? = null, + isActive: Boolean? = null + ): NetworkResult { + return repository.updateFinancialAccount(accountId, name, isActive) + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/AccountDetailScreen.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/AccountDetailScreen.kt new file mode 100644 index 0000000..31a4254 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/AccountDetailScreen.kt @@ -0,0 +1,550 @@ +package com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount + +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.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.outlined.AccountBalanceWallet +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material.icons.outlined.CreditCard +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +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.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.Brush +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 +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.resolum.intiva.core.common.state.UiState +import com.resolum.intiva.features.finances.domain.models.TransactionWithDesignResponse +import com.resolum.intiva.features.paymentmethodsandcategories.domain.models.FinancialAccount + +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material.icons.outlined.Edit + +private val HeroStart = Color(0xFF534AB7) +private val HeroEnd = Color(0xFF3B32A0) +private val IntivaBackground = Color(0xFFF4F0FA) +private val IntivaNeutral = Color(0xFF78767E) +private val IntivaTextPrimary = Color(0xFF1F1B2D) +private val IntivaAccent = Color(0xFFCDEB45) +private val IntivaError = Color(0xFFE53935) +private val IntivaIncome = Color(0xFF2E7D32) + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountDetailScreen( + viewModel: AccountDetailViewModel = hiltViewModel(), + onBackClick: () -> Unit = {}, + onEditClick: (Long) -> Unit = {} +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { viewModel.loadAccount() } + + Scaffold( + containerColor = IntivaBackground, + floatingActionButton = { + uiState.account?.let { account -> + FloatingActionButton( + onClick = { onEditClick(account.id) }, + shape = CircleShape, + containerColor = IntivaAccent, + contentColor = Color.Black + ) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = "Editar cuenta", + modifier = Modifier.size(28.dp) + ) + } + } + }, + topBar = { + TopAppBar( + title = { }, + navigationIcon = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp) + ) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Volver", + tint = IntivaTextPrimary + ) + } + Text( + text = "Volver a Cuentas", + fontSize = 14.sp, + color = IntivaNeutral + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.White) + ) + } + ) { paddingValues -> + when (val state = uiState.accountState) { + is UiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator(color = HeroStart) } + } + + is UiState.Success -> { + AccountDetailContent( + account = state.data, + transactions = uiState.recentTransactions, + transactionsLoading = uiState.transactionsState is UiState.Loading, + onDisableClick = { viewModel.disableAccount() }, + modifier = Modifier.padding(paddingValues) + ) + } + + is UiState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text( + text = state.message ?: "Error al cargar la cuenta", + color = IntivaError, + textAlign = TextAlign.Center, + modifier = Modifier.padding(32.dp) + ) + } + } + + UiState.Idle -> Unit + } + } +} + +@Composable +private fun AccountDetailContent( + account: FinancialAccount, + transactions: List, + transactionsLoading: Boolean, + onDisableClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + HeroCard(account = account) + + Spacer(modifier = Modifier.height(16.dp)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column { + SpendingLimitRow(account = account) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = Color(0xFFF0EDF7), + thickness = 1.dp + ) + StatusRow(isActive = account.isActive, onDisableClick = onDisableClick) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Movimientos Recientes", + fontSize = 20.sp, + fontWeight = FontWeight.ExtraBold, + color = IntivaTextPrimary + ) + if (transactions.isNotEmpty()) { + Text( + text = "Ver todos", + fontSize = 13.sp, + color = HeroStart, + fontWeight = FontWeight.SemiBold + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + when { + transactionsLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator(color = HeroStart, modifier = Modifier.size(28.dp)) } + } + + transactions.isEmpty() -> { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "No hay movimientos para esta cuenta", + fontSize = 13.sp, + color = IntivaNeutral, + textAlign = TextAlign.Center + ) + } + } + } + + else -> { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column { + transactions.forEachIndexed { index, tx -> + TransactionRow(transaction = tx) + if (index < transactions.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = Color(0xFFF0EDF7), + thickness = 1.dp + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} +@Composable +private fun HeroCard(account: FinancialAccount) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clip(RoundedCornerShape(20.dp)) + .background(Brush.linearGradient(colors = listOf(HeroStart, HeroEnd))) + .padding(24.dp) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = account.name, + fontSize = 22.sp, + fontWeight = FontWeight.ExtraBold, + color = Color.White + ) + Text( + text = accountTypeLabel(account.accountType), + fontSize = 13.sp, + color = Color.White.copy(alpha = 0.75f) + ) + } + Box( + modifier = Modifier + .size(44.dp) + .clip(RoundedCornerShape(12.dp)) + .background(IntivaAccent), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = heroIcon(account.accountType), + contentDescription = null, + tint = HeroStart, + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "SALDO DISPONIBLE", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.5.sp, + color = Color.White.copy(alpha = 0.75f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "S/ ${String.format("%,.2f", account.currentAmount)}", + fontSize = 32.sp, + fontWeight = FontWeight.ExtraBold, + color = IntivaAccent + ) + } + } +} + +@Composable +private fun SpendingLimitRow(account: FinancialAccount) { + var limitEnabled by remember { mutableStateOf(account.creditLimit != null) } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + InfoIcon(Icons.Outlined.Tune) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Límite de Gastos", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = IntivaTextPrimary + ) + Text( + text = if (account.creditLimit != null) + "Activado: S/ ${String.format("%,.2f", account.creditLimit)} / mes" + else + "Sin límite configurado", + fontSize = 11.sp, + color = IntivaNeutral + ) + } + Switch( + checked = limitEnabled, + onCheckedChange = { limitEnabled = it }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = HeroStart, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = Color(0xFFD0CDE4) + ) + ) + } +} + +@Composable +private fun StatusRow(isActive: Boolean, onDisableClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + InfoIcon(Icons.Outlined.Shield) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Estado", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = IntivaTextPrimary + ) + Text( + text = if (isActive) "Cuenta activa" else "Cuenta inactiva", + fontSize = 11.sp, + color = IntivaNeutral + ) + } + if (isActive) { + Button( + onClick = onDisableClick, + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFFEBEB), + contentColor = IntivaError + ), + elevation = null + ) { + Text(text = "Desactivar", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } + } + } +} + +@Composable +private fun TransactionRow(transaction: TransactionWithDesignResponse) { + val isIncome = transaction.transactionType.lowercase() == "income" + + val iconBg = runCatching { + Color(android.graphics.Color.parseColor( + transaction.categoryDesign?.categoryColor ?: if (isIncome) "#E8F5E9" else "#EDE8FF" + )).copy(alpha = 0.2f) + }.getOrElse { if (isIncome) Color(0xFFE8F5E9) else Color(0xFFEDE8FF) } + + val iconTint = if (isIncome) IntivaIncome else HeroStart + val txIcon = if (isIncome) Icons.Outlined.ArrowDownward else Icons.Outlined.ArrowUpward + + val amountColor = if (isIncome) IntivaIncome else IntivaTextPrimary + val amountPrefix = if (isIncome) "+ S/" else "- S/" + + val amountValue = transaction.amount.toDoubleOrNull() ?: 0.0 + + val dateDisplay = formatTransactionDate(transaction.registeredAt) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(iconBg), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = txIcon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(22.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = transaction.description, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = IntivaTextPrimary + ) + Text( + text = dateDisplay, + fontSize = 11.sp, + color = IntivaNeutral + ) + } + Text( + text = "$amountPrefix\n${String.format("%,.2f", amountValue)}", + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = amountColor, + textAlign = TextAlign.End, + lineHeight = 18.sp + ) + } +} + + +@Composable +private fun InfoIcon(icon: ImageVector) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color(0xFFF0EDF7)), + contentAlignment = Alignment.Center + ) { + Icon(imageVector = icon, contentDescription = null, tint = HeroStart, modifier = Modifier.size(18.dp)) + } +} + +private fun accountTypeLabel(type: String): String = when (type.lowercase()) { + "wallet", "billetera" -> "Cuenta Corriente" + "debit", "debit_card", "tarjeta_debito" -> "Tarjeta de Débito" + "credit", "credit_card", "tarjeta_credito" -> "Tarjeta de Crédito" + else -> type +} + +private fun heroIcon(type: String): ImageVector = when (type.lowercase()) { + "credit", "credit_card", "tarjeta_credito", + "debit", "debit_card", "tarjeta_debito" -> Icons.Outlined.CreditCard + else -> Icons.Outlined.AccountBalanceWallet +} + +/** + * Converts an ISO-8601 date string (e.g. "2025-03-12T20:00:00") to a human-readable label. + */ +private fun formatTransactionDate(raw: String): String { + return try { + val parts = raw.split("T") + val dateParts = parts[0].split("-") // ["2025","03","12"] + val timeParts = parts.getOrNull(1)?.substring(0, 5) ?: "" // "20:00" + val monthNames = listOf( + "", "Ene", "Feb", "Mar", "Abr", "May", "Jun", + "Jul", "Ago", "Sep", "Oct", "Nov", "Dic" + ) + val month = monthNames.getOrElse(dateParts[1].toIntOrNull() ?: 0) { "" } + "${dateParts[2]} $month, $timeParts" + } catch (e: Exception) { + raw + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/AccountDetailUiState.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/AccountDetailUiState.kt new file mode 100644 index 0000000..8fb4714 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/AccountDetailUiState.kt @@ -0,0 +1,12 @@ +package com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount + +import com.resolum.intiva.core.common.state.UiState +import com.resolum.intiva.features.finances.domain.models.TransactionWithDesignResponse +import com.resolum.intiva.features.paymentmethodsandcategories.domain.models.FinancialAccount + +data class AccountDetailUiState( + val account: FinancialAccount? = null, + val accountState: UiState = UiState.Idle, + val recentTransactions: List = emptyList(), + val transactionsState: UiState> = UiState.Idle +) diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/AccountDetailViewModel.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/AccountDetailViewModel.kt new file mode 100644 index 0000000..33a8bdb --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/AccountDetailViewModel.kt @@ -0,0 +1,98 @@ +package com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount + +import androidx.lifecycle.SavedStateHandle +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.finances.domain.usecase.GetTransactionsByOwnerIdUseCase +import com.resolum.intiva.features.paymentmethodsandcategories.domain.usecases.DisableFinancialAccountUseCase +import com.resolum.intiva.features.paymentmethodsandcategories.domain.usecases.GetFinancesAccountUseCase +import com.resolum.intiva.features.paymentmethodsandcategories.domain.usecases.UpdateFinancialAccountUseCase +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 javax.inject.Inject + +@HiltViewModel +class AccountDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val getFinancesAccountUseCase: GetFinancesAccountUseCase, + private val disableFinancialAccountUseCase: DisableFinancialAccountUseCase, + private val updateFinancialAccountUseCase: UpdateFinancialAccountUseCase, + private val getTransactionsByOwnerIdUseCase: GetTransactionsByOwnerIdUseCase +) : BaseViewModel() { + + private val accountId: Long = + savedStateHandle.get("accountId")?.toLongOrNull() ?: -1L + + private val _uiState = MutableStateFlow(AccountDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadAccount() { + safeLaunch { + _uiState.update { it.copy(accountState = UiState.Loading) } + val account = getFinancesAccountUseCase.getById(accountId) + if (account != null) { + _uiState.update { + it.copy(account = account, accountState = UiState.Success(account)) + } + loadRecentTransactions() + } else { + _uiState.update { + it.copy(accountState = UiState.Error("Cuenta no encontrada")) + } + } + } + } + + private fun loadRecentTransactions() { + safeLaunch { + _uiState.update { it.copy(transactionsState = UiState.Loading) } + when (val result = getTransactionsByOwnerIdUseCase()) { + is NetworkResult.Success -> { + val filtered = result.data + .flatMap { it.transactions } + .filter { it.financialAccountId == accountId } + .take(5) + _uiState.update { + it.copy( + recentTransactions = filtered, + transactionsState = UiState.Success(filtered) + ) + } + } + is NetworkResult.Error -> { + _uiState.update { + it.copy(transactionsState = UiState.Error(result.message, result.throwable)) + } + } + } + } + } + + fun disableAccount() { + safeLaunch { + _uiState.update { it.copy(accountState = UiState.Loading) } + when (val result = disableFinancialAccountUseCase(accountId)) { + is NetworkResult.Success -> _uiState.update { + it.copy(account = result.data, accountState = UiState.Success(result.data)) + } + is NetworkResult.Error -> _uiState.update { + it.copy(accountState = UiState.Error(result.message, result.throwable)) + } + } + } + } + + fun updateName(newName: String) { + safeLaunch { + when (val result = + updateFinancialAccountUseCase(accountId = accountId, name = newName)) { + is NetworkResult.Success -> _uiState.update { it.copy(account = result.data) } + is NetworkResult.Error -> { /* handled by BaseViewModel */ } + } + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/CreateFinancialAccountScreen.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/CreateFinancialAccountScreen.kt index 1877eca..3a3ad0b 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/CreateFinancialAccountScreen.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/CreateFinancialAccountScreen.kt @@ -1,6 +1,8 @@ package com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount 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 @@ -9,38 +11,31 @@ 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.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AccountCircle -import androidx.compose.material.icons.outlined.ArrowBack -import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Backspace +import androidx.compose.material.icons.outlined.AccountBalance +import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.CreditCard -import androidx.compose.material.icons.outlined.HelpOutline -import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults 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 @@ -50,24 +45,35 @@ 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.shadow -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType +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 androidx.lifecycle.compose.collectAsStateWithLifecycle import com.resolum.intiva.core.common.state.UiState -private val IntivaPrimary = Color(0xFF534AB7) -private val IntivaPrimaryDark = Color(0xFF3D329B) -private val IntivaSecondary = Color(0xFFCDEB45) -private val IntivaNeutral = Color(0xFF78767E) -private val IntivaBackground = Color(0xFFFFF8FF) -private val IntivaInputBackground = Color(0xFFF7F1FD) -private val IntivaBorder = Color(0xFFE8DFF0) -private val IntivaText = Color(0xFF22202A) +private val IntivaPrimary = Color(0xFF3D3A8C) +private val IntivaBackground = Color(0xFFF6F4FA) +private val IntivaNeutral = Color(0xFF78767E) +private val IntivaTextPrimary = Color(0xFF1F1B2D) + +private data class AccountTypeOption( + val label: String, + val value: String, + val icon: ImageVector +) + +private val accountTypes = listOf( + AccountTypeOption("Billetera", "wallet", Icons.Outlined.AccountBalanceWallet), + AccountTypeOption("Débito", "debit_card", Icons.Outlined.AccountBalance), + AccountTypeOption("Crédito", "credit_card", Icons.Outlined.CreditCard), +) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -78,25 +84,17 @@ fun CreateFinancialAccountScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var cardNumber by remember { mutableStateOf("") } - var holderName by remember { mutableStateOf("") } - var expirationDate by remember { mutableStateOf("") } - var cvv by remember { mutableStateOf("") } - var institution by remember { mutableStateOf("Bank of Family") } - var currentAmount by remember { mutableStateOf("0") } - var creditLimit by remember { mutableStateOf("") } - var isMainCard by remember { mutableStateOf(true) } - - var expanded by remember { mutableStateOf(false) } + var name by remember { mutableStateOf("") } + var institution by remember { mutableStateOf("") } + var selectedType by remember { mutableStateOf(accountTypes.first()) } + var amountRaw by remember { mutableStateOf("") } // digits only, no dot entry - val accountTypes = listOf( - "Tarjeta de débito" to "debit_card", - "Tarjeta de crédito" to "credit_card", - "Billetera" to "wallet" - ) + // Derived display: "0.00" when empty, otherwise insert decimal automatically + val amountDisplay = amountRaw.toDoubleOrNull()?.let { + String.format("%,.2f", it / 100.0) + } ?: "0.00" - var selectedAccountTypeLabel by remember { mutableStateOf(accountTypes.first().first) } - var selectedAccountTypeValue by remember { mutableStateOf(accountTypes.first().second) } + val amount = amountRaw.toDoubleOrNull()?.div(100.0) ?: 0.0 LaunchedEffect(uiState.createAccountState) { if (uiState.createAccountState is UiState.Success) { @@ -105,518 +103,320 @@ fun CreateFinancialAccountScreen( } } - val isFormValid = holderName.isNotBlank() && - cardNumber.length >= 4 && - expirationDate.isNotBlank() && - cvv.isNotBlank() + val isFormValid = name.isNotBlank() Scaffold( containerColor = IntivaBackground, topBar = { - TopAppBar( + CenterAlignedTopAppBar( title = { Text( - text = "Añadir Tarjeta", - color = IntivaText, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + text = "Agregar Cuenta", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = IntivaTextPrimary ) }, navigationIcon = { IconButton(onClick = onBackClick) { Icon( - imageVector = Icons.Outlined.ArrowBack, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver", - tint = IntivaText - ) - } - }, - actions = { - IconButton(onClick = { }) { - Icon( - imageVector = Icons.Outlined.HelpOutline, - contentDescription = "Ayuda", - tint = IntivaNeutral + tint = IntivaTextPrimary ) } }, - colors = TopAppBarDefaults.topAppBarColors( + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = Color.White ) ) } ) { paddingValues -> - Column( modifier = Modifier .fillMaxSize() - .background(IntivaBackground) .padding(paddingValues) - .padding(horizontal = 20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) + .navigationBarsPadding() ) { - Spacer(modifier = Modifier.height(10.dp)) - - IntivaCardPreview( - institution = institution.ifBlank { "Bank of Family" }, - holderName = holderName.ifBlank { "ALEJANDRO MARTÍNEZ" }, - cardNumber = cardNumber, - expirationDate = expirationDate.ifBlank { "12/28" } - ) - - Spacer(modifier = Modifier.height(4.dp)) - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded } + // ── Amount display ──────────────────────────────────────────── + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(top = 24.dp, bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - IntivaTextField( - value = selectedAccountTypeLabel, - onValueChange = {}, - label = "Tipo de cuenta", - readOnly = true, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - }, - modifier = Modifier - .menuAnchor() - .fillMaxWidth() + Text( + text = "SALDO INICIAL", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.5.sp, + color = IntivaNeutral ) - - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - accountTypes.forEach { accountType -> - DropdownMenuItem( - text = { Text(accountType.first) }, - onClick = { - selectedAccountTypeLabel = accountType.first - selectedAccountTypeValue = accountType.second - expanded = false - } - ) - } + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "S/.", + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = IntivaTextPrimary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = amountDisplay, + fontSize = 40.sp, + fontWeight = FontWeight.ExtraBold, + color = IntivaTextPrimary + ) } - } - - Column { - Text( - text = "Número de Tarjeta", - color = IntivaText, - style = MaterialTheme.typography.bodyMedium + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .width(48.dp) + .height(3.dp) + .clip(RoundedCornerShape(2.dp)) + .background(IntivaPrimary) ) + } - Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider(color = Color(0xFFEEEAF5)) - IntivaTextField( - value = cardNumber, - onValueChange = { value -> - cardNumber = value.filter { it.isDigit() }.take(16) - }, - label = "0000 0000 0000 0000", - leadingIcon = { - Icon( - imageVector = Icons.Outlined.CreditCard, - contentDescription = null, - tint = IntivaNeutral + // ── Name + type fields ──────────────────────────────────────── + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + placeholder = { + Text( + "Ej. Ahorros Familia", + color = Color(0xFFBBB7C8), + fontSize = 15.sp ) }, - keyboardType = KeyboardType.Number + label = { Text("Nombre de la cuenta", fontSize = 12.sp) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color(0xFFF5F3F9), + focusedBorderColor = IntivaPrimary, + unfocusedBorderColor = Color.Transparent, + focusedLabelColor = IntivaNeutral, + unfocusedLabelColor = IntivaNeutral, + cursorColor = IntivaPrimary + ) ) - } - Column { + Spacer(modifier = Modifier.height(14.dp)) + Text( - text = "Nombre del Titular", - color = IntivaText, - style = MaterialTheme.typography.bodyMedium + text = "Tipo de cuenta", + fontSize = 13.sp, + color = IntivaNeutral ) - Spacer(modifier = Modifier.height(8.dp)) - IntivaTextField( - value = holderName, - onValueChange = { holderName = it.uppercase() }, - label = "Como aparece en la tarjeta", - leadingIcon = { - Icon( - imageVector = Icons.Outlined.AccountCircle, - contentDescription = null, - tint = IntivaNeutral + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + accountTypes.forEach { type -> + TypeChip( + option = type, + isSelected = selectedType == type, + onClick = { selectedType = type }, + modifier = Modifier.weight(1f) ) } - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = "Fecha de Expiración", - color = IntivaText, - style = MaterialTheme.typography.bodyMedium - ) - - Spacer(modifier = Modifier.height(8.dp)) - - IntivaTextField( - value = expirationDate, - onValueChange = { value -> - expirationDate = value.take(5) - }, - label = "MM/YY", - leadingIcon = { - Icon( - imageVector = Icons.Outlined.CalendarMonth, - contentDescription = null, - tint = IntivaNeutral - ) - }, - keyboardType = KeyboardType.Number - ) } - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = "CVV", - color = IntivaText, - style = MaterialTheme.typography.bodyMedium - ) - - Spacer(modifier = Modifier.height(8.dp)) - - IntivaTextField( - value = cvv, - onValueChange = { value -> - cvv = value.filter { it.isDigit() }.take(4) - }, - label = "123", - leadingIcon = { - Icon( - imageVector = Icons.Outlined.Lock, - contentDescription = null, - tint = IntivaNeutral - ) - }, - keyboardType = KeyboardType.Number + // Credit limit field (credit card only) + if (selectedType.value == "credit_card") { + Spacer(modifier = Modifier.height(14.dp)) + OutlinedTextField( + value = institution, + onValueChange = { institution = it }, + placeholder = { Text("Institución (opcional)", color = Color(0xFFBBB7C8)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color(0xFFF5F3F9), + focusedBorderColor = IntivaPrimary, + unfocusedBorderColor = Color.Transparent, + cursorColor = IntivaPrimary + ) ) } } - if (selectedAccountTypeValue == "credit_card") { - IntivaTextField( - value = creditLimit, - onValueChange = { creditLimit = it }, - label = "Límite de crédito", - keyboardType = KeyboardType.Decimal - ) - } - - MainCardSwitch( - checked = isMainCard, - onCheckedChange = { isMainCard = it } - ) - - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = { - val visibleName = when { - holderName.isNotBlank() -> holderName - selectedAccountTypeValue == "wallet" -> "Billetera" - else -> selectedAccountTypeLabel - } - - viewModel.createFinancialAccount( - name = visibleName, - accountType = selectedAccountTypeValue, - currencyCode = "PEN", - currentAmount = currentAmount.toDoubleOrNull() ?: 0.0, - institution = institution.ifBlank { null }, - creditLimit = if (selectedAccountTypeValue == "credit_card") { - creditLimit.toDoubleOrNull() - } else { - null - } - ) - }, - enabled = isFormValid && uiState.createAccountState !is UiState.Loading, + Column( modifier = Modifier .fillMaxWidth() - .height(56.dp) - .shadow( - elevation = 10.dp, - shape = RoundedCornerShape(14.dp), - spotColor = IntivaSecondary - ), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors( - containerColor = IntivaSecondary, - contentColor = Color.Black, - disabledContainerColor = Color(0xFFE8E1EA), - disabledContentColor = IntivaNeutral - ) + .background(Color.White) + .padding(horizontal = 24.dp, vertical = 8.dp) ) { - Icon( - imageVector = Icons.Outlined.CheckCircle, - contentDescription = null + val keys = listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), + listOf(".", "0", "⌫") ) + keys.forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + row.forEach { key -> + NumericKey( + label = key, + modifier = Modifier + .weight(1f) + .height(60.dp), + onClick = { + when (key) { + "⌫" -> { + if (amountRaw.isNotEmpty()) { + amountRaw = amountRaw.dropLast(1) + } + } + "." -> { /* handled via integer-cent approach */ } + else -> { + if (amountRaw.length < 10) { + amountRaw += key + } + } + } + } + ) + } + } + } - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = if (uiState.createAccountState is UiState.Loading) { - "Guardando..." - } else { - "Confirmar Tarjeta" - }, - fontWeight = FontWeight.Bold - ) - } - } - } -} - -@Composable -private fun IntivaCardPreview( - institution: String, - holderName: String, - cardNumber: String, - expirationDate: String -) { - val lastFour = cardNumber.takeLast(4).ifBlank { "4242" } + Spacer(modifier = Modifier.height(8.dp)) - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .height(132.dp) - .shadow( - elevation = 18.dp, - shape = RoundedCornerShape(16.dp), - spotColor = IntivaPrimary - ), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = Color.Transparent - ) - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background( - brush = Brush.linearGradient( - colors = listOf( - IntivaPrimary, - IntivaPrimaryDark + Button( + onClick = { + viewModel.createFinancialAccount( + name = name, + accountType = selectedType.value, + currencyCode = "PEN", + currentAmount = amount, + institution = institution.ifBlank { null }, + creditLimit = null ) + }, + enabled = isFormValid && uiState.createAccountState !is UiState.Loading, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = IntivaPrimary, + contentColor = Color.White, + disabledContainerColor = Color(0xFFCCC8DC) ) - ) - .padding(18.dp) - ) { - Column( - modifier = Modifier.fillMaxSize() - ) { - Text( - text = "TARJETA INTIVA", - color = Color.White.copy(alpha = 0.55f), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium - ) - - Text( - text = institution, - color = Color.White, - style = MaterialTheme.typography.bodyMedium - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "•••• •••• •••• $lastFour", - color = Color.White, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.weight(1f)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom ) { - Column { - Text( - text = "TITULAR", - color = Color.White.copy(alpha = 0.55f), - style = MaterialTheme.typography.labelSmall - ) - - Text( - text = holderName, + if (uiState.createAccountState is UiState.Loading) { + CircularProgressIndicator( color = Color.White, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp ) - } - - Column( - horizontalAlignment = Alignment.End - ) { + } else { Text( - text = "EXPIRA", - color = Color.White.copy(alpha = 0.55f), - style = MaterialTheme.typography.labelSmall + text = "CONFIRMAR CUENTA", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp ) - - Text( - text = expirationDate, - color = Color.White, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Outlined.CheckCircle, + contentDescription = null, + modifier = Modifier.size(18.dp) ) } } - } - Card( - modifier = Modifier.align(Alignment.TopEnd), - shape = RoundedCornerShape(8.dp), - colors = CardDefaults.cardColors( - containerColor = Color.White.copy(alpha = 0.22f) - ) - ) { - Box( - modifier = Modifier - .size(width = 36.dp, height = 26.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "💳", - style = MaterialTheme.typography.bodyMedium - ) - } + Spacer(modifier = Modifier.height(12.dp)) } } } } @Composable -private fun IntivaTextField( - value: String, - onValueChange: (String) -> Unit, - label: String, - modifier: Modifier = Modifier, - readOnly: Boolean = false, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - keyboardType: KeyboardType = KeyboardType.Text +private fun TypeChip( + option: AccountTypeOption, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier ) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - readOnly = readOnly, - placeholder = { - Text( - text = label, - color = Color(0xFFC8C0D3) - ) - }, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = keyboardType - ), + val bg = if (isSelected) Color(0xFFEDE8FF) else Color(0xFFF5F3F9) + val border = if (isSelected) IntivaPrimary else Color.Transparent + val tint = if (isSelected) IntivaPrimary else IntivaNeutral + + Column( modifier = modifier - .fillMaxWidth() - .height(58.dp), - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = IntivaInputBackground, - unfocusedContainerColor = IntivaInputBackground, - disabledContainerColor = IntivaInputBackground, - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - focusedTextColor = IntivaText, - unfocusedTextColor = IntivaText, - cursorColor = IntivaPrimary + .clip(RoundedCornerShape(12.dp)) + .border(1.5.dp, border, RoundedCornerShape(12.dp)) + .background(bg) + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = option.icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(26.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = option.label, + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = tint, + textAlign = TextAlign.Center ) - ) + } } +// ── Numeric key ─────────────────────────────────────────────────────────────── + @Composable -private fun MainCardSwitch( - checked: Boolean, - onCheckedChange: (Boolean) -> Unit +private fun NumericKey( + label: String, + modifier: Modifier = Modifier, + onClick: () -> Unit ) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp), - colors = CardDefaults.cardColors( - containerColor = Color.White - ), - border = androidx.compose.foundation.BorderStroke( - width = 1.dp, - color = IntivaBorder - ) + Box( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Card( - shape = RoundedCornerShape(50.dp), - colors = CardDefaults.cardColors( - containerColor = Color(0xFFEDE8FF) - ) - ) { - Icon( - imageVector = Icons.Outlined.StarOutline, - contentDescription = null, - tint = IntivaPrimary, - modifier = Modifier.padding(10.dp) - ) - } - - Spacer(modifier = Modifier.width(12.dp)) - - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = "Tarjeta Principal", - color = IntivaText, - style = MaterialTheme.typography.bodyMedium - ) - - Text( - text = "Para pagos recurrentes y automáticos", - color = IntivaNeutral, - style = MaterialTheme.typography.labelSmall - ) - } - - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = IntivaPrimary, - uncheckedThumbColor = Color.White, - uncheckedTrackColor = Color(0xFFD9D1E5) - ) + if (label == "⌫") { + Icon( + imageVector = Icons.AutoMirrored.Filled.Backspace, + contentDescription = "Borrar", + tint = Color(0xFF78767E), + modifier = Modifier.size(22.dp) + ) + } else { + Text( + text = label, + fontSize = 24.sp, + fontWeight = FontWeight.Light, + color = IntivaTextPrimary ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/EditFinancialAccountScreen.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/EditFinancialAccountScreen.kt new file mode 100644 index 0000000..604e3f3 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/EditFinancialAccountScreen.kt @@ -0,0 +1,314 @@ +package com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount + +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.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.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material3.Button +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.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.resolum.intiva.core.common.state.UiState + +private val IntivaPrimary = Color(0xFF3D3A8C) +private val IntivaBackground = Color(0xFFF6F4FA) +private val IntivaNeutral = Color(0xFF78767E) +private val IntivaTextPrimary = Color(0xFF1F1B2D) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditFinancialAccountScreen( + viewModel: EditFinancialAccountViewModel = hiltViewModel(), + onAccountUpdated: () -> Unit = {}, + onBackClick: () -> Unit = {} +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + var name by remember { mutableStateOf("") } + var isActive by remember { mutableStateOf(true) } + var isInitialized by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.loadAccount() + } + + LaunchedEffect(uiState.accountState) { + val account = uiState.account + if (uiState.accountState is UiState.Success && account != null && !isInitialized) { + name = account.name + isActive = account.isActive + isInitialized = true + } + } + + LaunchedEffect(uiState.updateState) { + if (uiState.updateState is UiState.Success) { + viewModel.resetUpdateState() + onAccountUpdated() + } + } + + Scaffold( + containerColor = IntivaBackground, + topBar = { + CenterAlignedTopAppBar( + modifier = Modifier.statusBarsPadding(), + title = { + Text( + text = "Editar Cuenta", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = IntivaTextPrimary + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Volver", + tint = IntivaTextPrimary + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = Color.White + ) + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .background(IntivaBackground) + ) { + when (val state = uiState.accountState) { + is UiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = IntivaPrimary) + } + } + + is UiState.Success -> { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Nombre de la cuenta", + style = MaterialTheme.typography.labelLarge, + color = IntivaTextPrimary + ) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + placeholder = { Text("Nombre de la cuenta", color = Color(0xFFBBB7C8)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedBorderColor = IntivaPrimary, + unfocusedBorderColor = Color(0xFFE0DCE6), + cursorColor = IntivaPrimary + ) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color(0xFFF0EDF7)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Shield, + contentDescription = null, + tint = IntivaPrimary, + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Estado", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = IntivaTextPrimary + ) + Text( + text = if (isActive) "Cuenta activa" else "Cuenta inactiva", + fontSize = 11.sp, + color = IntivaNeutral + ) + } + Switch( + checked = isActive, + onCheckedChange = { isActive = it }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = IntivaPrimary, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = Color(0xFFD0CDE4) + ) + ) + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFEDE8FF).copy(alpha = 0.5f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = IntivaPrimary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "Solo puedes modificar el nombre y el estado de activación de tu cuenta financiera.", + fontSize = 12.sp, + color = IntivaPrimary, + lineHeight = 16.sp + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Button( + onClick = { + viewModel.updateFinancialAccount(name = name, isActive = isActive) + }, + enabled = name.isNotBlank() && uiState.updateState !is UiState.Loading, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + .navigationBarsPadding(), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = IntivaPrimary, + contentColor = Color.White, + disabledContainerColor = Color(0xFFCCC8DC) + ) + ) { + if (uiState.updateState is UiState.Loading) { + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else { + Text( + text = "GUARDAR CAMBIOS", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Outlined.CheckCircle, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + } + } + } + + is UiState.Error -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = state.message ?: "Error al cargar la cuenta", + color = Color.Red, + textAlign = TextAlign.Center + ) + } + } + + UiState.Idle -> Unit + } + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/EditFinancialAccountViewModel.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/EditFinancialAccountViewModel.kt new file mode 100644 index 0000000..74c9b42 --- /dev/null +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/EditFinancialAccountViewModel.kt @@ -0,0 +1,75 @@ +package com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount + +import androidx.lifecycle.SavedStateHandle +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.FinancialAccount +import com.resolum.intiva.features.paymentmethodsandcategories.domain.usecases.GetFinancesAccountUseCase +import com.resolum.intiva.features.paymentmethodsandcategories.domain.usecases.UpdateFinancialAccountUseCase +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 javax.inject.Inject + +data class EditFinancialAccountUiState( + val account: FinancialAccount? = null, + val accountState: UiState = UiState.Idle, + val updateState: UiState = UiState.Idle +) + +@HiltViewModel +class EditFinancialAccountViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val getFinancesAccountUseCase: GetFinancesAccountUseCase, + private val updateFinancialAccountUseCase: UpdateFinancialAccountUseCase +) : BaseViewModel() { + + private val accountId: Long = savedStateHandle.get("accountId")?.toLongOrNull() ?: -1L + + private val _uiState = MutableStateFlow(EditFinancialAccountUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadAccount() { + safeLaunch { + _uiState.update { it.copy(accountState = UiState.Loading) } + val account = getFinancesAccountUseCase.getById(accountId) + if (account != null) { + _uiState.update { + it.copy(account = account, accountState = UiState.Success(account)) + } + } else { + _uiState.update { + it.copy(accountState = UiState.Error("Cuenta no encontrada")) + } + } + } + } + + fun updateFinancialAccount(name: String, isActive: Boolean) { + safeLaunch { + _uiState.update { it.copy(updateState = UiState.Loading) } + when (val result = updateFinancialAccountUseCase(accountId, name, isActive)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + account = result.data, + updateState = UiState.Success(result.data) + ) + } + } + is NetworkResult.Error -> { + _uiState.update { + it.copy(updateState = UiState.Error(result.message, result.throwable)) + } + } + } + } + } + + fun resetUpdateState() { + _uiState.update { it.copy(updateState = UiState.Idle) } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/FinancialAccountScreen.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/FinancialAccountScreen.kt index 747309f..d3fdb71 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/FinancialAccountScreen.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/FinancialAccountScreen.kt @@ -1,57 +1,65 @@ package com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.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.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn 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.outlined.AccountBalance +import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.CreditCard -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.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 import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.resolum.intiva.core.common.state.UiState +import com.resolum.intiva.features.paymentmethodsandcategories.domain.models.FinancialAccount import com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount.components.DisableAccountDialog -import com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount.components.FinancialAccountCard -private val IntivaPrimary = Color(0xFF534AB7) -private val IntivaSecondary = Color(0xFFCDEB45) -private val IntivaNeutral = Color(0xFF78767E) -private val IntivaBackground = Color(0xFFF4F0FA) -private val IntivaCardBackground = Color(0xFFF8F5FF) +private val IntivaPrimary = Color(0xFF534AB7) +private val IntivaSecondary = Color(0xFFCDEB45) +private val IntivaNeutral = Color(0xFF78767E) +private val IntivaBackground = Color(0xFFF4F0FA) +private val IntivaTextPrimary = Color(0xFF1F1B2D) -@OptIn(ExperimentalMaterial3Api::class) @Composable fun FinancialAccountScreen( viewModel: FinancialAccountViewModel = hiltViewModel(), - onAddAccountClick: () -> Unit = {} + onAddAccountClick: () -> Unit = {}, + onAccountClick: ((Long) -> Unit)? = null ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -59,7 +67,6 @@ fun FinancialAccountScreen( viewModel.getFinancialAccounts() } - // Diálogo de confirmación de inhabilitación if (uiState.showDisableConfirmDialog && uiState.accountToDisable != null) { DisableAccountDialog( account = uiState.accountToDisable!!, @@ -70,17 +77,19 @@ fun FinancialAccountScreen( Scaffold( containerColor = IntivaBackground, - topBar = { - TopAppBar( - title = { - Text( - text = "Cuentas financieras", - style = MaterialTheme.typography.headlineSmall, - color = Color.White - ) - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = IntivaPrimary) - ) + floatingActionButton = { + FloatingActionButton( + onClick = onAddAccountClick, + shape = CircleShape, + containerColor = IntivaSecondary, + contentColor = Color.Black + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = "Agregar cuenta", + modifier = Modifier.size(28.dp) + ) + } } ) { paddingValues -> @@ -89,57 +98,86 @@ fun FinancialAccountScreen( .fillMaxSize() .background(IntivaBackground) .padding(paddingValues) - .padding(horizontal = 20.dp) ) { - Spacer(modifier = Modifier.height(18.dp)) - - FilledTonalButton( - onClick = onAddAccountClick, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = IntivaSecondary, - contentColor = Color.Black - ) + // ── Header ────────────────────────────────────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .padding(horizontal = 20.dp, vertical = 20.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween ) { - Icon(imageVector = Icons.Outlined.Add, contentDescription = null) - Text( - text = "Agregar tarjeta o billetera", - modifier = Modifier.padding(start = 8.dp) - ) + Column { + Text( + text = "Cuentas", + fontSize = 30.sp, + fontWeight = FontWeight.ExtraBold, + color = IntivaTextPrimary + ) + Text( + text = "Gestiona tus balances y\ntarjetas", + fontSize = 13.sp, + color = IntivaNeutral, + lineHeight = 18.sp + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = "BALANCE TOTAL", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = IntivaNeutral, + letterSpacing = 1.sp + ) + Text( + text = "S/ ${String.format("%,.2f", viewModel.totalBalance)}", + fontSize = 26.sp, + fontWeight = FontWeight.ExtraBold, + color = IntivaPrimary + ) + } } - Spacer(modifier = Modifier.height(20.dp)) - + // ── Content ───────────────────────────────────────────────────── when (uiState.accountsState) { is UiState.Loading -> { - Column( + Box( modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = IntivaPrimary) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = "Cargando cuentas...", - style = MaterialTheme.typography.bodyMedium, - color = IntivaNeutral - ) } } is UiState.Success -> { if (uiState.accounts.isEmpty()) { - EmptyFinancialAccountsContent(onAddAccountClick = onAddAccountClick) + EmptyAccountsContent(onAddAccountClick = onAddAccountClick) } else { LazyColumn( - contentPadding = PaddingValues(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 16.dp, bottom = 100.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { items(uiState.accounts) { account -> - FinancialAccountCard( + AccountListCard( account = account, - onDisableClick = { viewModel.onDisableAccountClick(it) } + onClick = { onAccountClick?.invoke(account.id) } + ) + } + + item { + Text( + text = "Mantén un registro claro de tus finanzas\nconectando todas tus cuentas en un solo lugar.", + fontSize = 13.sp, + color = IntivaNeutral, + textAlign = TextAlign.Center, + lineHeight = 18.sp, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 16.dp) ) } } @@ -147,7 +185,7 @@ fun FinancialAccountScreen( } is UiState.Error -> { - EmptyFinancialAccountsContent(onAddAccountClick = onAddAccountClick) + EmptyAccountsContent(onAddAccountClick = onAddAccountClick) } UiState.Idle -> Unit @@ -156,65 +194,188 @@ fun FinancialAccountScreen( } } + @Composable -private fun EmptyFinancialAccountsContent(onAddAccountClick: () -> Unit) { - Column( +private fun AccountListCard( + account: FinancialAccount, + onClick: () -> Unit +) { + val (icon, iconBg, iconTint, badge) = accountVisuals(account.accountType) + + val amountColor = if (account.accountType.lowercase().contains("credit")) { + Color(0xFFE53935) + } else { + IntivaTextPrimary + } + + Card( modifier = Modifier - .fillMaxSize() - .padding(top = 70.dp), - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = IntivaCardBackground), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically ) { - Column( - modifier = Modifier.padding(28.dp), - horizontalAlignment = Alignment.CenterHorizontally + // Icon circle + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(iconBg), + contentAlignment = Alignment.Center ) { - Card( - shape = RoundedCornerShape(50.dp), - colors = CardDefaults.cardColors(containerColor = IntivaPrimary) - ) { - Icon( - imageVector = Icons.Outlined.CreditCard, - contentDescription = null, - tint = Color.White, - modifier = Modifier.padding(16.dp) - ) - } + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(24.dp) + ) + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { Text( - text = "Aún no tienes cuentas registradas", - style = MaterialTheme.typography.titleMedium, - color = Color.Black + text = account.name, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = IntivaTextPrimary ) + Spacer(modifier = Modifier.height(2.dp)) + badge?.let { badgeText -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(Color(0xFF8BC34A)) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = badgeText, + fontSize = 11.sp, + color = IntivaNeutral + ) + } + } ?: run { + val subtitle = accountSubtitle(account) + Text( + text = subtitle, + fontSize = 11.sp, + color = IntivaNeutral + ) + } + } - Spacer(modifier = Modifier.height(8.dp)) - + Column(horizontalAlignment = Alignment.End) { Text( - text = "Agrega una tarjeta de débito, tarjeta de crédito o billetera digital.", - style = MaterialTheme.typography.bodyMedium, - color = IntivaNeutral + text = "S/ ${String.format("%,.2f", account.currentAmount)}", + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = IntivaTextPrimary ) - - Spacer(modifier = Modifier.height(20.dp)) - - Button( - onClick = onAddAccountClick, - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors( - containerColor = IntivaPrimary, - contentColor = Color.White + if (account.accountType.lowercase().contains("credit")) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Deuda", + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFE53935) ) - ) { - Text(text = "Agregar cuenta") } } } } -} \ No newline at end of file +} + + +private data class AccountVisuals( + val icon: ImageVector, + val iconBackground: Color, + val iconTint: Color, + val badge: String? +) + +private fun accountVisuals(type: String): AccountVisuals = when (type.lowercase()) { + "wallet", "billetera" -> AccountVisuals( + icon = Icons.Outlined.AccountBalanceWallet, + iconBackground = Color(0xFFEDE8FF), + iconTint = Color(0xFF534AB7), + badge = "Principal" + ) + "debit", "debit_card", "tarjeta_debito" -> AccountVisuals( + icon = Icons.Outlined.AccountBalance, + iconBackground = Color(0xFFE3F2FD), + iconTint = Color(0xFF1565C0), + badge = null + ) + "credit", "credit_card", "tarjeta_credito" -> AccountVisuals( + icon = Icons.Outlined.CreditCard, + iconBackground = Color(0xFFE8F5E9), + iconTint = Color(0xFF2E7D32), + badge = null + ) + else -> AccountVisuals( + icon = Icons.Outlined.AccountBalanceWallet, + iconBackground = Color(0xFFEDE8FF), + iconTint = IntivaPrimary, + badge = null + ) +} + +private fun accountSubtitle(account: FinancialAccount): String { + return when { + account.institution != null -> account.institution + account.creditLimit != null -> "Cierre: 15 de Oct" + else -> "" + } +} + +@Composable +private fun EmptyAccountsContent(onAddAccountClick: () -> Unit) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(Color(0xFFEDE8FF)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.AccountBalanceWallet, + contentDescription = null, + tint = IntivaPrimary, + modifier = Modifier.size(36.dp) + ) + } + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "Sin cuentas registradas", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = IntivaTextPrimary, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Toca el botón + para agregar\ntu primera cuenta.", + fontSize = 13.sp, + color = IntivaNeutral, + textAlign = TextAlign.Center, + lineHeight = 18.sp + ) + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/FinancialAccountViewModel.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/FinancialAccountViewModel.kt index c9519fa..99e5159 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/FinancialAccountViewModel.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/FinancialAccountViewModel.kt @@ -7,6 +7,7 @@ import com.resolum.intiva.features.paymentmethodsandcategories.domain.models.Fin import com.resolum.intiva.features.paymentmethodsandcategories.domain.usecases.CreateFinancialAccountUseCase import com.resolum.intiva.features.paymentmethodsandcategories.domain.usecases.DisableFinancialAccountUseCase import com.resolum.intiva.features.paymentmethodsandcategories.domain.usecases.GetFinancesAccountUseCase +import com.resolum.intiva.features.paymentmethodsandcategories.domain.usecases.UpdateFinancialAccountUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -24,7 +25,8 @@ import javax.inject.Inject class FinancialAccountViewModel @Inject constructor( private val getFinancialAccountsUseCase: GetFinancesAccountUseCase, private val createFinancialAccountUseCase: CreateFinancialAccountUseCase, - private val disableFinancialAccountUseCase: DisableFinancialAccountUseCase + private val disableFinancialAccountUseCase: DisableFinancialAccountUseCase, + private val updateFinancialAccountUseCase: UpdateFinancialAccountUseCase ) : BaseViewModel() { private val _uiState = MutableStateFlow(FinancialAccountUiState()) @@ -146,6 +148,30 @@ class FinancialAccountViewModel @Inject constructor( _uiState.update { it.copy(disableAccountState = UiState.Idle) } } + fun updateFinancialAccount( + accountId: Long, + name: String? = null, + isActive: Boolean? = null + ) { + safeLaunch { + when (val result = updateFinancialAccountUseCase(accountId, name, isActive)) { + is NetworkResult.Success -> _uiState.update { state -> + state.copy( + accounts = state.accounts.map { + if (it.id == result.data.id) result.data else it + } + ) + } + is NetworkResult.Error -> { /* handled by BaseViewModel */ } + } + } + } + + val totalBalance: Double + get() = _uiState.value.accounts + .filter { it.isActive } + .sumOf { it.currentAmount } + override fun handleError(throwable: Throwable) { _uiState.update { it.copy( diff --git a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/components/FinancialAccountCard.kt b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/components/FinancialAccountCard.kt index c96123f..dd68086 100644 --- a/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/components/FinancialAccountCard.kt +++ b/app/src/main/java/com/resolum/intiva/features/paymentmethodsandcategories/presentation/financialaccount/components/FinancialAccountCard.kt @@ -1,6 +1,7 @@ package com.resolum.intiva.features.paymentmethodsandcategories.presentation.financialaccount.components import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -38,13 +39,14 @@ private val IntivaError = Color(0xFFD32F2F) fun FinancialAccountCard( account: FinancialAccount, modifier: Modifier = Modifier, + onCardClick: ((FinancialAccount) -> Unit)? = null, onDisableClick: ((FinancialAccount) -> Unit)? = null ) { - val icon = when (account.accountType.lowercase()) { - "credit", "credit_card", "tarjeta_credito" -> Icons.Outlined.CreditCard - "debit", "debit_card", "tarjeta_debito" -> Icons.Outlined.AccountBalance - "wallet", "billetera" -> Icons.Outlined.AccountBalanceWallet - else -> Icons.Outlined.AccountBalanceWallet + val (icon, tintColor) = when (account.accountType.lowercase()) { + "credit", "credit_card", "tarjeta_credito" -> Icons.Outlined.CreditCard to Color(0xFFE91E63) + "debit", "debit_card", "tarjeta_debito" -> Icons.Outlined.AccountBalance to Color(0xFF2196F3) + "wallet", "billetera" -> Icons.Outlined.AccountBalanceWallet to Color(0xFF4CAF50) + else -> Icons.Outlined.AccountBalanceWallet to IntivaPrimary } val accountTypeText = when (account.accountType.lowercase()) { @@ -55,7 +57,9 @@ fun FinancialAccountCard( } Card( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .clickable(enabled = onCardClick != null) { onCardClick?.invoke(account) }, shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = IntivaCardBackground), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) @@ -65,12 +69,12 @@ fun FinancialAccountCard( Row(verticalAlignment = Alignment.CenterVertically) { Card( shape = RoundedCornerShape(50.dp), - colors = CardDefaults.cardColors(containerColor = IntivaPrimary) + colors = CardDefaults.cardColors(containerColor = tintColor.copy(alpha = 0.15f)) ) { Icon( imageVector = icon, contentDescription = null, - tint = Color.White, + tint = tintColor, modifier = Modifier.padding(12.dp).size(22.dp) ) } @@ -94,7 +98,7 @@ fun FinancialAccountCard( Spacer(modifier = Modifier.height(18.dp)) Text( - text = "${account.currencyCode} ${account.currentAmount}", + text = "${account.currencyCode} ${String.format("%,.2f", account.currentAmount)}", style = MaterialTheme.typography.headlineSmall, color = IntivaPrimary ) @@ -107,7 +111,7 @@ fun FinancialAccountCard( account.creditLimit?.let { Spacer(modifier = Modifier.height(6.dp)) Text( - text = "Límite: ${account.currencyCode} $it", + text = "Límite: ${account.currencyCode} ${String.format("%,.2f", it)}", style = MaterialTheme.typography.bodySmall, color = IntivaNeutral ) @@ -118,7 +122,7 @@ fun FinancialAccountCard( Text( text = if (account.isActive) "Activa" else "Inactiva", style = MaterialTheme.typography.labelMedium, - color = if (account.isActive) IntivaPrimary else IntivaNeutral + color = if (account.isActive) Color(0xFF4CAF50) else IntivaNeutral ) if (account.isActive && onDisableClick != null) { @@ -144,4 +148,4 @@ fun FinancialAccountCard( } } } -} \ No newline at end of file +}