diff --git a/app/src/main/java/com/resolum/intiva/core/data/local/datastore/TokenDataStore.kt b/app/src/main/java/com/resolum/intiva/core/data/local/datastore/TokenDataStore.kt index d892f71..31ddfdc 100644 --- a/app/src/main/java/com/resolum/intiva/core/data/local/datastore/TokenDataStore.kt +++ b/app/src/main/java/com/resolum/intiva/core/data/local/datastore/TokenDataStore.kt @@ -24,6 +24,7 @@ class TokenDataStore @Inject constructor( private val AUTH_TOKEN = stringPreferencesKey("auth_token") private val USER_ID = longPreferencesKey("user_id") private val GROUP_ID = longPreferencesKey("group_id") + private val LAST_USER_ID = longPreferencesKey("last_user_id") } /** Flow that emits the current authentication token, or null if not set. */ @@ -38,11 +39,18 @@ class TokenDataStore @Inject constructor( val groupId: Flow = dataStore.data.map { it[GROUP_ID] } - /** Saves the provided authentication token to DataStore preferences. */ + /** Saves the provided authentication token to DataStore preferences. + * If the userId differs from the last known user, the cached groupId is cleared + * so a different user won't use a stale group from the previous session. */ suspend fun saveToken(token: String, userId: Long) { - dataStore.edit { - it[AUTH_TOKEN] = token - it[USER_ID] = userId + dataStore.edit { prefs -> + val lastUserId = prefs[LAST_USER_ID] + prefs[AUTH_TOKEN] = token + prefs[USER_ID] = userId + prefs[LAST_USER_ID] = userId + if (lastUserId != null && lastUserId != userId) { + prefs.remove(GROUP_ID) + } } } diff --git a/app/src/main/java/com/resolum/intiva/core/network/interceptor/LoggingInterceptor.kt b/app/src/main/java/com/resolum/intiva/core/network/interceptor/LoggingInterceptor.kt index 10d7812..13878d9 100644 --- a/app/src/main/java/com/resolum/intiva/core/network/interceptor/LoggingInterceptor.kt +++ b/app/src/main/java/com/resolum/intiva/core/network/interceptor/LoggingInterceptor.kt @@ -46,7 +46,7 @@ class LoggingInterceptor( Log.d("HttpLogger", "HEADERS:") headers.forEach { (name, value) -> if (name.equals("Authorization", ignoreCase = true)) { - Log.d("HttpLogger", " $name: Bearer ${value.take(20)}...") + Log.d("HttpLogger", " $name: ${value.take(20)}...") } else { Log.d("HttpLogger", " $name: $value") } diff --git a/app/src/main/java/com/resolum/intiva/features/household/data/repositories/InvitationRepositoryImpl.kt b/app/src/main/java/com/resolum/intiva/features/household/data/repositories/InvitationRepositoryImpl.kt index 51e0cde..a2a65b6 100644 --- a/app/src/main/java/com/resolum/intiva/features/household/data/repositories/InvitationRepositoryImpl.kt +++ b/app/src/main/java/com/resolum/intiva/features/household/data/repositories/InvitationRepositoryImpl.kt @@ -54,12 +54,13 @@ class InvitationRepositoryImpl @Inject constructor( ) } - override suspend fun acceptInvitationByToken(token: String): NetworkResult = safeCall { + override suspend fun acceptInvitationByToken(token: String): NetworkResult = safeCall { val userId = sessionRepository.getUserId() ?: throw IllegalStateException("User ID not found in session") val dto = familyFacadeService.getPublicInvitation(token) val invitationId = dto.id ?: throw IllegalStateException("Invitation ID not found in public response") + val familyId = dto.familyId ?: throw IllegalStateException("Family ID not found in public response") familyFacadeService.acceptInvitation(userId, invitationId) - Unit + familyId } override suspend fun rejectInvitationByToken(token: String): NetworkResult = safeCall { diff --git a/app/src/main/java/com/resolum/intiva/features/household/domain/repositories/InvitationRepository.kt b/app/src/main/java/com/resolum/intiva/features/household/domain/repositories/InvitationRepository.kt index e5e7388..f14c14d 100644 --- a/app/src/main/java/com/resolum/intiva/features/household/domain/repositories/InvitationRepository.kt +++ b/app/src/main/java/com/resolum/intiva/features/household/domain/repositories/InvitationRepository.kt @@ -18,7 +18,7 @@ interface InvitationRepository { suspend fun rejectInvitation(invitationId: Long): NetworkResult - suspend fun acceptInvitationByToken(token: String): NetworkResult + suspend fun acceptInvitationByToken(token: String): NetworkResult suspend fun rejectInvitationByToken(token: String): NetworkResult diff --git a/app/src/main/java/com/resolum/intiva/features/household/domain/usecase/AcceptInvitationByTokenUseCase.kt b/app/src/main/java/com/resolum/intiva/features/household/domain/usecase/AcceptInvitationByTokenUseCase.kt index 9b01c58..ec51163 100644 --- a/app/src/main/java/com/resolum/intiva/features/household/domain/usecase/AcceptInvitationByTokenUseCase.kt +++ b/app/src/main/java/com/resolum/intiva/features/household/domain/usecase/AcceptInvitationByTokenUseCase.kt @@ -7,7 +7,7 @@ import jakarta.inject.Inject class AcceptInvitationByTokenUseCase @Inject constructor( private val invitationRepository: InvitationRepository ) { - suspend operator fun invoke(token: String): NetworkResult { + suspend operator fun invoke(token: String): NetworkResult { if (token.isBlank()) { return NetworkResult.Error("Token is required") } diff --git a/app/src/main/java/com/resolum/intiva/features/household/presentation/family/FamilyScreen.kt b/app/src/main/java/com/resolum/intiva/features/household/presentation/family/FamilyScreen.kt index a44a2ed..7b0f404 100644 --- a/app/src/main/java/com/resolum/intiva/features/household/presentation/family/FamilyScreen.kt +++ b/app/src/main/java/com/resolum/intiva/features/household/presentation/family/FamilyScreen.kt @@ -96,6 +96,7 @@ fun FamilyScreen( var showQrOptionsSheet by remember { mutableStateOf(false) } var showQrScanner by remember { mutableStateOf(false) } + var showCreateFamilySheet by remember { mutableStateOf(false) } var showMyQrSheet by remember { mutableStateOf(false) } var showCameraDeniedDialog by remember { mutableStateOf(false) } var qrScanConsumed by remember { mutableStateOf(false) } @@ -190,9 +191,14 @@ fun FamilyScreen( is UiState.Idle -> { NoFamilyContent( - onCreateFamily = { name, description -> - viewModel.createFamily(name, description) - } + onJoinFamily = { + if (cameraPermissionGranted) { + showQrScanner = true + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + onCreateFamily = { showCreateFamilySheet = true } ) } @@ -367,6 +373,16 @@ fun FamilyScreen( ) } + if (showCreateFamilySheet) { + CreateFamilyBottomSheet( + onDismiss = { showCreateFamilySheet = false }, + onCreateFamily = { name, description -> + viewModel.createFamily(name, description) + showCreateFamilySheet = false + } + ) + } + if (showCameraDeniedDialog) { AlertDialog( onDismissRequest = { showCameraDeniedDialog = false }, @@ -633,11 +649,9 @@ private fun MyQrBottomSheet( @Composable private fun NoFamilyContent( - onCreateFamily: (name: String, description: String) -> Unit + onJoinFamily: () -> Unit, + onCreateFamily: () -> Unit ) { - var familyName by remember { mutableStateOf("") } - var familyDesc by remember { mutableStateOf("") } - Column( modifier = Modifier .fillMaxSize() @@ -672,52 +686,58 @@ private fun NoFamilyContent( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Crea un grupo para gestionar finanzas\nen familia y hacer seguimiento conjunto.", + text = "Únete a una familia existente o crea\nuna nueva para gestionar finanzas juntos.", fontSize = 14.sp, color = IntivaColors.TextSecondary, textAlign = TextAlign.Center, lineHeight = 20.sp ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(40.dp)) - OutlinedTextField( - value = familyName, - onValueChange = { familyName = it }, - label = { Text("Nombre del grupo") }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - singleLine = true - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = familyDesc, - onValueChange = { familyDesc = it }, - label = { Text("Descripción (opcional)") }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - maxLines = 2 - ) + Button( + onClick = onJoinFamily, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(28.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFCCFF00), + contentColor = Color(0xFF0D0D0D) + ) + ) { + Icon( + imageVector = Icons.Outlined.QrCodeScanner, + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Unirme a un grupo familiar", + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ) + } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(16.dp)) Button( - onClick = { - if (familyName.isNotBlank()) { - onCreateFamily(familyName.trim(), familyDesc.trim()) - } - }, + onClick = onCreateFamily, modifier = Modifier .fillMaxWidth() - .height(52.dp), - shape = RoundedCornerShape(26.dp), - colors = ButtonDefaults.buttonColors(containerColor = IntivaColors.PrimaryBrand), - enabled = familyName.isNotBlank() + .height(56.dp), + shape = RoundedCornerShape(28.dp), + colors = ButtonDefaults.buttonColors(containerColor = IntivaColors.PrimaryBrand) ) { + Icon( + imageVector = Icons.Default.Group, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) Text( - text = "Crear grupo familiar", + text = "Crear un grupo familiar", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp @@ -725,3 +745,101 @@ private fun NoFamilyContent( } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CreateFamilyBottomSheet( + onDismiss: () -> Unit, + onCreateFamily: (name: String, description: String) -> Unit +) { + var familyName by remember { mutableStateOf("") } + var familyDesc by remember { mutableStateOf("") } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color.White + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Crear grupo familiar", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = IntivaColors.TextPrimary, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Completa los datos para crear tu grupo", + fontSize = 14.sp, + color = IntivaColors.TextSecondary, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = familyName, + onValueChange = { familyName = it }, + label = { Text("Nombre del grupo") }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + singleLine = true + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = familyDesc, + onValueChange = { familyDesc = it }, + label = { Text("Descripción (opcional)") }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + maxLines = 2 + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + if (familyName.isNotBlank()) { + onCreateFamily(familyName.trim(), familyDesc.trim()) + } + }, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(26.dp), + colors = ButtonDefaults.buttonColors(containerColor = IntivaColors.PrimaryBrand), + enabled = familyName.isNotBlank() + ) { + Text( + text = "Crear grupo familiar", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Cancelar", + color = IntivaColors.PrimaryBrand, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + modifier = Modifier + .padding(8.dp) + .clickable { onDismiss() } + ) + } + } +} diff --git a/app/src/main/java/com/resolum/intiva/features/household/presentation/family/FamilyViewModel.kt b/app/src/main/java/com/resolum/intiva/features/household/presentation/family/FamilyViewModel.kt index 2b070e5..11d9db9 100644 --- a/app/src/main/java/com/resolum/intiva/features/household/presentation/family/FamilyViewModel.kt +++ b/app/src/main/java/com/resolum/intiva/features/household/presentation/family/FamilyViewModel.kt @@ -59,6 +59,7 @@ class FamilyViewModel @Inject constructor( when (val result = acceptInvitationByTokenUseCase(token)) { is NetworkResult.Success -> { + sessionRepository.saveGroupId(result.data) _uiState.update { it.copy(scanResultState = UiState.Success("Te uniste al grupo exitosamente")) } loadFamily() loadMembers() diff --git a/app/src/main/java/com/resolum/intiva/features/household/presentation/family/components/QrCodeScannerContent.kt b/app/src/main/java/com/resolum/intiva/features/household/presentation/family/components/QrCodeScannerContent.kt index 61f95b4..6517ade 100644 --- a/app/src/main/java/com/resolum/intiva/features/household/presentation/family/components/QrCodeScannerContent.kt +++ b/app/src/main/java/com/resolum/intiva/features/household/presentation/family/components/QrCodeScannerContent.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -40,6 +41,7 @@ fun QrCodeScannerContent( modifier: Modifier = Modifier ) { val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } val analyzerExecutor = remember { Executors.newSingleThreadExecutor() } var detected by remember { mutableStateOf(false) } @@ -99,7 +101,7 @@ fun QrCodeScannerContent( try { cameraProvider.unbindAll() cameraProvider.bindToLifecycle( - ctx as androidx.lifecycle.LifecycleOwner, + lifecycleOwner, cameraSelector, preview, imageAnalysis diff --git a/app/src/main/java/com/resolum/intiva/features/household/presentation/invitation/InvitationDetailViewModel.kt b/app/src/main/java/com/resolum/intiva/features/household/presentation/invitation/InvitationDetailViewModel.kt index aff082f..9819883 100644 --- a/app/src/main/java/com/resolum/intiva/features/household/presentation/invitation/InvitationDetailViewModel.kt +++ b/app/src/main/java/com/resolum/intiva/features/household/presentation/invitation/InvitationDetailViewModel.kt @@ -75,6 +75,7 @@ class InvitationDetailViewModel @Inject constructor( _actionState.value = InvitationActionState.Loading when (val result = acceptInvitationByTokenUseCase(token)) { is NetworkResult.Success -> { + sessionRepository.saveGroupId(result.data) _actionState.value = InvitationActionState.Success("Invitación aceptada con éxito") } is NetworkResult.Error -> { diff --git a/app/src/main/java/com/resolum/intiva/features/household/presentation/invite/InviteViewModel.kt b/app/src/main/java/com/resolum/intiva/features/household/presentation/invite/InviteViewModel.kt index d5c43e3..d5abcff 100644 --- a/app/src/main/java/com/resolum/intiva/features/household/presentation/invite/InviteViewModel.kt +++ b/app/src/main/java/com/resolum/intiva/features/household/presentation/invite/InviteViewModel.kt @@ -218,6 +218,7 @@ class InviteViewModel @Inject constructor( when (val result = acceptInvitationByTokenUseCase(token)) { is NetworkResult.Success -> { + sessionRepository.saveGroupId(result.data) _uiState.update { it.copy(actionState = UiState.Success("Invitación aceptada")) } } is NetworkResult.Error -> { diff --git a/app/src/test/java/com/resolum/intiva/features/iam/data/remote/services/AuthServiceTest.kt b/app/src/test/java/com/resolum/intiva/features/iam/data/remote/services/AuthServiceTest.kt index 679214a..266cc9d 100644 --- a/app/src/test/java/com/resolum/intiva/features/iam/data/remote/services/AuthServiceTest.kt +++ b/app/src/test/java/com/resolum/intiva/features/iam/data/remote/services/AuthServiceTest.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import retrofit2.Retrofit @@ -59,7 +59,6 @@ class AuthServiceTest { assertEquals("/authentication/sign-up", request.path) assertEquals("POST", request.method) - assertTrue(response.isSuccessful) - assertEquals("test@mail.com", response.body()?.email) + assertEquals("test@mail.com", response.email) } } \ No newline at end of file diff --git a/app/src/test/java/com/resolum/intiva/features/iam/data/repositories/AuthRepositoryImplTest.kt b/app/src/test/java/com/resolum/intiva/features/iam/data/repositories/AuthRepositoryImplTest.kt index ddd0ed8..bce7d1c 100644 --- a/app/src/test/java/com/resolum/intiva/features/iam/data/repositories/AuthRepositoryImplTest.kt +++ b/app/src/test/java/com/resolum/intiva/features/iam/data/repositories/AuthRepositoryImplTest.kt @@ -1,22 +1,29 @@ package com.resolum.intiva.features.iam.data.repositories -import androidx.camera.core.ImageProcessor import com.resolum.intiva.core.network.model.NetworkResult +import com.resolum.intiva.features.iam.data.remote.AuthFacadeService +import com.resolum.intiva.features.iam.data.remote.models.SignUpRequestDto import com.resolum.intiva.features.iam.data.remote.models.SignUpResponseDto -import com.resolum.intiva.features.iam.data.remote.services.AuthService import com.resolum.intiva.features.iam.domain.models.SignUpRequest +import com.resolum.intiva.features.iam.domain.repositories.SessionRepository +import com.resolum.intiva.features.communications.domain.repositories.NotificationDeviceRepository import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.test.runTest -import org.junit.Assert.* +import org.junit.Assert.assertTrue import org.junit.Test -import retrofit2.Response class AuthRepositoryImplTest { - private val authService = mockk() + private val authFacadeService = mockk() + private val sessionRepository = mockk() + private val notificationDeviceRepository = mockk() - private val authRepository = AuthRepositoryImpl(authService) + private val authRepository = AuthRepositoryImpl( + authFacadeService, + sessionRepository, + notificationDeviceRepository + ) @Test fun signUp(): Unit = runTest { @@ -24,10 +31,8 @@ class AuthRepositoryImplTest { val request = SignUpRequest("test@mail.com", "1234") coEvery { - authService.signUp(any()) - } returns Response.success( - SignUpResponseDto(id = "1", email = "test@mail.com") - ) + authFacadeService.signUp(any()) + } returns SignUpResponseDto(id = 1L, email = "test@mail.com") val result = authRepository.signUp(request)