diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/api/build.gradle.kts b/api/build.gradle.kts new file mode 100644 index 00000000..004abde6 --- /dev/null +++ b/api/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("java-library") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.ksp) +} +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + + dependencies { + implementation(project(":foundations")) + + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.core) + implementation(libs.kotlinx.coroutines) + implementation(libs.retrofit) + implementation(libs.retrofit.serialization) + ksp(libs.moshi.kotlin.codegen) + + // Test dependencies + testImplementation(libs.junit) + } +} +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} diff --git a/api/consumer-rules.pro b/api/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/api/proguard-rules.pro b/api/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/api/src/main/AndroidManifest.xml b/api/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/api/src/main/kotlin/com/gravatar/app/di/ApiModule.kt b/api/src/main/kotlin/com/gravatar/app/di/ApiModule.kt new file mode 100644 index 00000000..8f87eb46 --- /dev/null +++ b/api/src/main/kotlin/com/gravatar/app/di/ApiModule.kt @@ -0,0 +1,27 @@ +package com.gravatar.app.di + +import com.gravatar.app.restapi.GravatarApi +import com.gravatar.app.services.GravatarService +import com.gravatar.app.services.GravatarServiceImpl +import com.squareup.moshi.Moshi +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +val apiModule = module { + single { + Retrofit.Builder() + .baseUrl("https://api.gravatar.com/v3/") + .addConverterFactory( + MoshiConverterFactory.create() + ) + .build() + } + single { get().create(GravatarApi::class.java) } + single { + GravatarServiceImpl( + gravatarApi = get(), + dispatchers = get() + ) + } +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/gravatar/app/model/DeleteAccountStatus.kt b/api/src/main/kotlin/com/gravatar/app/model/DeleteAccountStatus.kt new file mode 100644 index 00000000..fe59f182 --- /dev/null +++ b/api/src/main/kotlin/com/gravatar/app/model/DeleteAccountStatus.kt @@ -0,0 +1,9 @@ +package com.gravatar.app.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DeleteAccountStatus( + @Json(name = "status") val status: String = "disabled", +) \ No newline at end of file diff --git a/api/src/main/kotlin/com/gravatar/app/restapi/GravatarApi.kt b/api/src/main/kotlin/com/gravatar/app/restapi/GravatarApi.kt new file mode 100644 index 00000000..90d16e48 --- /dev/null +++ b/api/src/main/kotlin/com/gravatar/app/restapi/GravatarApi.kt @@ -0,0 +1,24 @@ +package com.gravatar.app.restapi + +import com.gravatar.app.model.DeleteAccountStatus +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.POST + +/** + * Interface defining the Gravatar API endpoints. + */ +internal interface GravatarApi { + /** + * Disables the user's Gravatar account. + * Makes a POST request to https://api.gravatar.com/v3/me/status + * + * @return [Result] indicating success or failure of the operation + */ + @POST("me/status") + suspend fun disableAccount( + @Header("Authorization") authorization: String, + @Body body: DeleteAccountStatus = DeleteAccountStatus() + ): Response +} diff --git a/api/src/main/kotlin/com/gravatar/app/services/GravatarService.kt b/api/src/main/kotlin/com/gravatar/app/services/GravatarService.kt new file mode 100644 index 00000000..81c64b07 --- /dev/null +++ b/api/src/main/kotlin/com/gravatar/app/services/GravatarService.kt @@ -0,0 +1,29 @@ +package com.gravatar.app.services + +import com.gravatar.app.foundations.DispatcherProvider +import com.gravatar.app.restapi.GravatarApi +import kotlinx.coroutines.withContext + +internal class GravatarServiceImpl( + private val gravatarApi: GravatarApi, + private val dispatchers: DispatcherProvider +) : GravatarService { + + override suspend fun deleteProfile(authorization: String): Result = withContext(dispatchers.io) { + return@withContext try { + val response = gravatarApi.disableAccount("Bearer $authorization") + + if (response.isSuccessful) { + Result.success(Unit) + } else { + Result.failure(Exception("Failed to disable account.")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} + +interface GravatarService { + suspend fun deleteProfile(authorization: String): Result +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d13b4872..99639ff2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ kotlin = "2.1.21" lifecycleRuntimeKtx = "2.9.1" navigationCompose = "2.9.0" mockk = "1.14.2" +retrofit = "3.0.0" roborazzi = "1.45.1" robolectric = "4.14.1" room = "2.7.2" @@ -27,6 +28,7 @@ ucrop = "2.2.11" androidxTestCore = "1.6.1" constraintLayout = "1.1.0" qrose= "1.0.1" +moshi = "1.15.2" [libraries] kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinCoroutines" } @@ -72,6 +74,9 @@ ktor-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" ktor-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } ktor-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-serialization = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } diff --git a/homeUi/screenshotTests/roborazzi/com.gravatar.app.homeUi.presentation.home.components.topbar.components.AboutAppDialogTest.aboutAppDialogVisible.png b/homeUi/screenshotTests/roborazzi/com.gravatar.app.homeUi.presentation.home.components.topbar.components.AboutAppDialogTest.aboutAppDialogVisible.png index f9e7d36a..e7bf2bcd 100644 Binary files a/homeUi/screenshotTests/roborazzi/com.gravatar.app.homeUi.presentation.home.components.topbar.components.AboutAppDialogTest.aboutAppDialogVisible.png and b/homeUi/screenshotTests/roborazzi/com.gravatar.app.homeUi.presentation.home.components.topbar.components.AboutAppDialogTest.aboutAppDialogVisible.png differ diff --git a/homeUi/screenshotTests/roborazzi/com.gravatar.app.homeUi.presentation.home.components.topbar.components.AboutAppDialogTest.deleteConfirmationBottomSheetVisible.png b/homeUi/screenshotTests/roborazzi/com.gravatar.app.homeUi.presentation.home.components.topbar.components.AboutAppDialogTest.deleteConfirmationBottomSheetVisible.png new file mode 100644 index 00000000..a187d055 Binary files /dev/null and b/homeUi/screenshotTests/roborazzi/com.gravatar.app.homeUi.presentation.home.components.topbar.components.AboutAppDialogTest.deleteConfirmationBottomSheetVisible.png differ diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/di/HomeUiModule.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/di/HomeUiModule.kt index 6160dd41..a7998a8d 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/di/HomeUiModule.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/di/HomeUiModule.kt @@ -5,6 +5,7 @@ import com.gravatar.app.homeUi.presentation.DrawableUtils import com.gravatar.app.homeUi.presentation.FileUtils import com.gravatar.app.homeUi.presentation.home.HomeViewModel import com.gravatar.app.homeUi.presentation.home.components.topbar.TopBarPickerPopupViewModel +import com.gravatar.app.homeUi.presentation.home.components.topbar.components.about.AboutAppDialogViewModel import com.gravatar.app.homeUi.presentation.home.gravatar.GravatarViewModel import com.gravatar.app.homeUi.presentation.home.profile.ProfileViewModel import com.gravatar.app.homeUi.presentation.home.share.ShareViewModel @@ -21,6 +22,7 @@ val homeUiModule = module { viewModelOf(::ProfileViewModel) viewModelOf(::GravatarViewModel) viewModelOf(::TopBarPickerPopupViewModel) + viewModelOf(::AboutAppDialogViewModel) viewModelOf(::ShareViewModel) viewModelOf(::HomeViewModel) diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/AboutAppDialog.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/AboutAppDialog.kt similarity index 56% rename from homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/AboutAppDialog.kt rename to homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/AboutAppDialog.kt index e11262a7..0d15a7e8 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/AboutAppDialog.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/AboutAppDialog.kt @@ -1,17 +1,30 @@ -package com.gravatar.app.homeUi.presentation.home.components.topbar.components +package com.gravatar.app.homeUi.presentation.home.components.topbar.components.about import android.content.Context import android.content.Intent import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton 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.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -26,30 +39,75 @@ import com.gravatar.app.design.components.dialog.GravatarDialog import com.gravatar.app.design.theme.GravatarAppTheme import com.gravatar.app.homeUi.AppVersion import com.gravatar.app.homeUi.R +import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun AboutAppDialog( onDismissRequest: () -> Unit, + viewModel: AboutAppDialogViewModel = koinViewModel() ) { val appVersion: AppVersion = koinInject() + val uiState by viewModel.uiState.collectAsState() + GravatarDialog( onDismissRequest = onDismissRequest, content = { - AboutAppDialogContent( - appVersion = appVersion.value, - onDone = onDismissRequest, - modifier = Modifier - ) + if (uiState.isLoading) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onSurfaceVariant, + strokeWidth = 4.dp, + modifier = Modifier.padding(16.dp) + ) + } + } else { + AboutAppDialogContent( + appVersion = appVersion.value, + onDone = onDismissRequest, + onEvent = viewModel::onEvent, + modifier = Modifier + ) + } + if (uiState.showDeleteAccountErrorAlert) { + BasicAlertDialog( + onDismissRequest = { viewModel.dismissErrorMessage() } + ) { + Surface( + modifier = Modifier.wrapContentWidth().wrapContentHeight(), + shape = MaterialTheme.shapes.large, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = stringResource(R.string.about_app_dialog_delete_profile_error_message)) + Spacer(modifier = Modifier.height(24.dp)) + TextButton( + onClick = { viewModel.dismissErrorMessage() }, + modifier = Modifier.align(Alignment.End) + ) { + Text(text = stringResource(R.string.done_button_cta)) + } + } + } + } + } } ) + + if (uiState.isDeleteConfirmationVisible) { + DeleteConfirmationBottomSheet( + onDismiss = { viewModel.onEvent(AboutAppDialogEvent.OnHideDeleteConfirmation) }, + onConfirm = { viewModel.onEvent(AboutAppDialogEvent.OnConfirmDeleteAccount) } + ) + } } @Composable internal fun AboutAppDialogContent( appVersion: String, onDone: () -> Unit, + onEvent: (AboutAppDialogEvent) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -109,6 +167,28 @@ internal fun AboutAppDialogContent( } ) } + Column { + Text( + text = stringResource(R.string.about_app_dialog_delete_account), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold + ), + modifier = modifier.padding(top = 4.dp), + ) + DialogText( + text = stringResource(R.string.about_app_dialog_delete_profile_description), + ) + Text( + text = stringResource(R.string.about_app_dialog_delete_account_button), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier + .padding(top = 16.dp, bottom = 8.dp) + .clickable { + onEvent(AboutAppDialogEvent.OnShowDeleteConfirmation) + } + ) + } PrimaryButton( text = stringResource(R.string.done_button_cta), onClick = onDone, @@ -150,7 +230,8 @@ private fun AboutAppDialogContentPreview() { AboutAppDialogContent( appVersion = "0.0.1", onDone = { }, - modifier = Modifier.fillMaxWidth(), + onEvent = { _ -> }, + modifier = Modifier.fillMaxWidth() ) } } diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/AboutAppDialogEvent.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/AboutAppDialogEvent.kt new file mode 100644 index 00000000..07654c54 --- /dev/null +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/AboutAppDialogEvent.kt @@ -0,0 +1,7 @@ +package com.gravatar.app.homeUi.presentation.home.components.topbar.components.about + +sealed class AboutAppDialogEvent { + data object OnShowDeleteConfirmation : AboutAppDialogEvent() + data object OnHideDeleteConfirmation : AboutAppDialogEvent() + data object OnConfirmDeleteAccount : AboutAppDialogEvent() +} diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/AboutAppDialogState.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/AboutAppDialogState.kt new file mode 100644 index 00000000..21972bcb --- /dev/null +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/AboutAppDialogState.kt @@ -0,0 +1,7 @@ +package com.gravatar.app.homeUi.presentation.home.components.topbar.components.about + +internal data class AboutAppDialogState( + val isDeleteConfirmationVisible: Boolean = false, + val isLoading: Boolean = false, + val showDeleteAccountErrorAlert: Boolean = false, +) diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/AboutAppDialogViewModel.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/AboutAppDialogViewModel.kt new file mode 100644 index 00000000..5876b31a --- /dev/null +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/AboutAppDialogViewModel.kt @@ -0,0 +1,71 @@ +package com.gravatar.app.homeUi.presentation.home.components.topbar.components.about + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gravatar.app.usercomponent.domain.usecase.DeleteUserProfile +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal class AboutAppDialogViewModel( + private val deleteUserProfile: DeleteUserProfile, +) : ViewModel() { + + private val _uiState = MutableStateFlow(AboutAppDialogState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onEvent(event: AboutAppDialogEvent) { + when (event) { + AboutAppDialogEvent.OnShowDeleteConfirmation -> { + showDeleteConfirmation() + } + + AboutAppDialogEvent.OnHideDeleteConfirmation -> { + hideDeleteConfirmation() + } + + AboutAppDialogEvent.OnConfirmDeleteAccount -> { + deleteProfile() + hideDeleteConfirmation() + } + } + } + + fun dismissErrorMessage() { + _uiState.update { currentState -> + currentState.copy( + showDeleteAccountErrorAlert = false, + isLoading = false + ) + } + } + + private fun showDeleteConfirmation() { + _uiState.update { currentState -> + currentState.copy(isDeleteConfirmationVisible = true) + } + } + + private fun hideDeleteConfirmation() { + _uiState.update { currentState -> + currentState.copy(isDeleteConfirmationVisible = false) + } + } + + private fun deleteProfile() { + viewModelScope.launch { + _uiState.update { currentState -> + currentState.copy(isLoading = true) + } + deleteUserProfile().onFailure { + _uiState.update { currentState -> + currentState.copy( + showDeleteAccountErrorAlert = true + ) + } + } + } + } +} diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/DeleteConfirmationBottomSheet.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/DeleteConfirmationBottomSheet.kt new file mode 100644 index 00000000..bf1a9c67 --- /dev/null +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/about/DeleteConfirmationBottomSheet.kt @@ -0,0 +1,96 @@ +package com.gravatar.app.homeUi.presentation.home.components.topbar.components.about + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.gravatar.app.homeUi.R +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DeleteConfirmationBottomSheet( + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.about_app_dialog_delete_profile_confirmation_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.about_app_dialog_delete_profile_confirmation_description), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + ) { + TextButton( + onClick = { + scope.launch { + sheetState.hide() + onDismiss() + } + }, + modifier = Modifier.weight(1f), + ) { + Text(text = stringResource(R.string.about_app_dialog_delete_profile_confirmation_cancel)) + } + + TextButton( + onClick = { + scope.launch { + sheetState.hide() + onConfirm() + } + }, + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.about_app_dialog_delete_account_button), + color = MaterialTheme.colorScheme.error + ) + } + } + } + } +} diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/gravatar/GravatarScreen.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/gravatar/GravatarScreen.kt index 0e02298e..59fb7261 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/gravatar/GravatarScreen.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/gravatar/GravatarScreen.kt @@ -54,7 +54,7 @@ import com.gravatar.app.homeUi.GravatarFileProvider import com.gravatar.app.homeUi.R import com.gravatar.app.homeUi.presentation.home.components.ErrorViewWithRetry import com.gravatar.app.homeUi.presentation.home.components.PermissionRationaleDialog -import com.gravatar.app.homeUi.presentation.home.components.topbar.components.AboutAppDialog +import com.gravatar.app.homeUi.presentation.home.components.topbar.components.about.AboutAppDialog import com.gravatar.app.homeUi.presentation.home.gravatar.components.AvatarDeletionConfirmationDialog import com.gravatar.app.homeUi.presentation.home.gravatar.components.AvatarOption import com.gravatar.app.homeUi.presentation.home.gravatar.components.CollapsibleTopAppBar diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/profile/ProfileScreen.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/profile/ProfileScreen.kt index 065e117e..46d2cc03 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/profile/ProfileScreen.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/profile/ProfileScreen.kt @@ -39,7 +39,7 @@ import androidx.lifecycle.repeatOnLifecycle import com.gravatar.app.design.components.snackbar.SnackbarType import com.gravatar.app.design.components.snackbar.showGravatarSnackbar import com.gravatar.app.homeUi.R -import com.gravatar.app.homeUi.presentation.home.components.topbar.components.AboutAppDialog +import com.gravatar.app.homeUi.presentation.home.components.topbar.components.about.AboutAppDialog import com.gravatar.app.homeUi.presentation.home.profile.about.AboutInputField import com.gravatar.app.homeUi.presentation.home.profile.about.AboutSection import com.gravatar.app.homeUi.presentation.home.profile.header.AnimatedProfileHeader diff --git a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareScreen.kt b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareScreen.kt index 184e6274..40797d0e 100644 --- a/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareScreen.kt +++ b/homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareScreen.kt @@ -30,7 +30,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import com.gravatar.app.design.theme.GravatarAppTheme import com.gravatar.app.homeUi.GravatarFileProvider -import com.gravatar.app.homeUi.presentation.home.components.topbar.components.AboutAppDialog +import com.gravatar.app.homeUi.presentation.home.components.topbar.components.about.AboutAppDialog import com.gravatar.app.homeUi.presentation.home.share.components.ExpandedQrCode import com.gravatar.app.homeUi.presentation.home.share.components.ItemDivider import com.gravatar.app.homeUi.presentation.home.share.components.PrivateInformationDialog diff --git a/homeUi/src/main/res/values/strings.xml b/homeUi/src/main/res/values/strings.xml index aa181f4d..4967abc5 100644 --- a/homeUi/src/main/res/values/strings.xml +++ b/homeUi/src/main/res/values/strings.xml @@ -76,6 +76,7 @@ Legal Terms of Service Privacy Policy + Delete account Done Share info from your Gravatar profile. Name @@ -87,4 +88,10 @@ Expand QR Code for sharing contact information QR Code Close + Delete account + No longer using Gravatar? Delete your account here. + Deleting your Gravatar profile will immediately prevent all access to it. + "Your data will be permanently deleted after 30 days. During this period, you can still restore your profile by logging in at gravatar.com using your browser. After 30 days, it will no longer be recoverable." + Cancel + An error has occurred while deleting your account diff --git a/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/AboutAppDialogTest.kt b/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/AboutAppDialogTest.kt index 4b6e4ff7..d8970242 100644 --- a/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/AboutAppDialogTest.kt +++ b/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/AboutAppDialogTest.kt @@ -1,6 +1,9 @@ package com.gravatar.app.homeUi.presentation.home.components.topbar.components +import androidx.compose.material3.ExperimentalMaterial3Api import com.gravatar.app.design.theme.GravatarAppTheme +import com.gravatar.app.homeUi.presentation.home.components.topbar.components.about.AboutAppDialogContent +import com.gravatar.app.homeUi.presentation.home.components.topbar.components.about.DeleteConfirmationBottomSheet import com.gravatar.app.testUtils.roborazzi.RoborazziTest import org.junit.Test @@ -12,6 +15,18 @@ class AboutAppDialogTest : RoborazziTest() { AboutAppDialogContent( appVersion = "1.0.0", onDone = {}, + onEvent = { _ -> } + ) + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Test + fun deleteConfirmationBottomSheetVisible() = screenshotTest { + GravatarAppTheme { + DeleteConfirmationBottomSheet( + onDismiss = {}, + onConfirm = {} ) } } diff --git a/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/AboutAppDialogViewModelTest.kt b/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/AboutAppDialogViewModelTest.kt new file mode 100644 index 00000000..5bb028ea --- /dev/null +++ b/homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/components/topbar/components/AboutAppDialogViewModelTest.kt @@ -0,0 +1,129 @@ +package com.gravatar.app.homeUi.presentation.home.components.topbar.components + +import app.cash.turbine.test +import com.gravatar.app.homeUi.presentation.home.components.topbar.components.about.AboutAppDialogEvent +import com.gravatar.app.homeUi.presentation.home.components.topbar.components.about.AboutAppDialogState +import com.gravatar.app.homeUi.presentation.home.components.topbar.components.about.AboutAppDialogViewModel +import com.gravatar.app.testUtils.CoroutineTestRule +import com.gravatar.app.usercomponent.domain.usecase.DeleteUserProfile +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AboutAppDialogViewModelTest { + private val testDispatcher = StandardTestDispatcher() + + @get:Rule + var coroutineTestRule = CoroutineTestRule(testDispatcher) + + private val deleteUserProfile: DeleteUserProfile = mockk() + private lateinit var viewModel: AboutAppDialogViewModel + + @Before + fun setup() { + coEvery { deleteUserProfile.invoke() } returns Result.success(Unit) + + viewModel = AboutAppDialogViewModel(deleteUserProfile) + } + + @Test + fun `when OnShowDeleteConfirmation event is received then delete confirmation is shown`() = runTest { + // When + viewModel.onEvent(AboutAppDialogEvent.OnShowDeleteConfirmation) + advanceUntilIdle() + + // Then + viewModel.uiState.test { + val expectedState = AboutAppDialogState( + isDeleteConfirmationVisible = true, + isLoading = false, + ) + assertEquals(expectedState, awaitItem()) + } + } + + @Test + fun `when OnHideDeleteConfirmation event is received then delete confirmation is hidden`() = runTest { + // Given + viewModel.onEvent(AboutAppDialogEvent.OnShowDeleteConfirmation) + + // When + viewModel.onEvent(AboutAppDialogEvent.OnHideDeleteConfirmation) + advanceUntilIdle() + + viewModel.uiState.test { + val expectedState = AboutAppDialogState( + isDeleteConfirmationVisible = false, + isLoading = false, + ) + assertEquals(expectedState, awaitItem()) + } + } + + @Test + fun `when OnConfirmDeleteAccount event is received then deleteUserProfile is invoked and confirmation is hidden`() = runTest { + // Given + viewModel.onEvent(AboutAppDialogEvent.OnShowDeleteConfirmation) + + // When + viewModel.onEvent(AboutAppDialogEvent.OnConfirmDeleteAccount) + advanceUntilIdle() + + // Then + coVerify { deleteUserProfile.invoke() } + viewModel.uiState.test { + val expectedState = AboutAppDialogState( + isDeleteConfirmationVisible = false, + isLoading = true, + ) + assertEquals(expectedState, awaitItem()) + } + } + + @Test + fun `when OnConfirmDeleteAccount event is received then isLoading is set to true`() = runTest { + // Given + viewModel.onEvent(AboutAppDialogEvent.OnShowDeleteConfirmation) + + // When + viewModel.onEvent(AboutAppDialogEvent.OnConfirmDeleteAccount) + advanceUntilIdle() + + // Then + viewModel.uiState.test { + val expectedState = AboutAppDialogState( + isDeleteConfirmationVisible = false, + isLoading = true, + ) + assertEquals(expectedState, awaitItem()) + } + } + + @Test + fun `when OnConfirmDeleteAccount event is received and deleteUserProfile fails then isLoading is set to false`() = runTest { + // Given + coEvery { deleteUserProfile.invoke() } returns Result.failure(Exception("Failed to delete profile")) + viewModel = AboutAppDialogViewModel(deleteUserProfile) + + // When + viewModel.onEvent(AboutAppDialogEvent.OnConfirmDeleteAccount) + + // Then + viewModel.uiState.test { + val expectedState = AboutAppDialogState( + isDeleteConfirmationVisible = false, + isLoading = false, + ) + assertEquals(expectedState, awaitItem()) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index af91a4b3..2046cba4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,3 +39,4 @@ include(":foundations") include(":clock") include(":design") include(":networkMonitor") +include(":api") diff --git a/userComponent/build.gradle.kts b/userComponent/build.gradle.kts index 7740fb9a..80e94584 100644 --- a/userComponent/build.gradle.kts +++ b/userComponent/build.gradle.kts @@ -17,6 +17,7 @@ android { dependencies { implementation(project(":foundations")) implementation(project(":clock")) + implementation(project(":api")) implementation(libs.kotlinx.coroutines) implementation(libs.androidx.datastore.prefs) diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/HttpClientModule.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/HttpClientModule.kt index ac49c4dd..a28bdaaa 100644 --- a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/HttpClientModule.kt +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/HttpClientModule.kt @@ -1,5 +1,6 @@ package com.gravatar.app.usercomponent.di +import com.gravatar.app.di.apiModule import com.gravatar.app.usercomponent.data.interceptors.UnauthorizeInterceptor import com.gravatar.app.usercomponent.domain.usecase.Logout import com.gravatar.services.AvatarService @@ -35,4 +36,6 @@ internal val httpClientModule = module { } single { ProfileService(okHttpClient = get()) } single { AvatarService(okHttpClient = get()) } + + includes(apiModule) } diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt index 3fa2bba9..e1bc99c1 100644 --- a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt @@ -15,6 +15,8 @@ import com.gravatar.app.usercomponent.domain.repository.ProfileRepository import com.gravatar.app.usercomponent.domain.repository.UserRepository import com.gravatar.app.usercomponent.domain.usecase.DeleteUserAvatar import com.gravatar.app.usercomponent.domain.usecase.DeleteUserAvatarUseCase +import com.gravatar.app.usercomponent.domain.usecase.DeleteUserProfile +import com.gravatar.app.usercomponent.domain.usecase.DeleteUserProfileUseCase import com.gravatar.app.usercomponent.domain.usecase.FetchAvatarsUseCase import com.gravatar.app.usercomponent.domain.usecase.FetchUserAvatars import com.gravatar.app.usercomponent.domain.usecase.GetAvatarUrl @@ -58,6 +60,7 @@ val userComponentModule = module { factoryOf(::UpdateUserSharePreferencesUseCase) { bind() } factoryOf(::GetPrivateContactInfoUseCase) { bind() } factoryOf(::UpdatePrivateContactInfoUseCase) { bind() } + factoryOf(::DeleteUserProfileUseCase) { bind() } factoryOf(::UserSharePreferencesOperations) { bind() } factoryOf(::PrivateContactInfoOperations) { bind() } factoryOf(::WordPressClient) diff --git a/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/usecase/DeleteUserProfileUseCase.kt b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/usecase/DeleteUserProfileUseCase.kt new file mode 100644 index 00000000..d4cf26db --- /dev/null +++ b/userComponent/src/main/kotlin/com/gravatar/app/usercomponent/domain/usecase/DeleteUserProfileUseCase.kt @@ -0,0 +1,27 @@ +package com.gravatar.app.usercomponent.domain.usecase + +import com.gravatar.app.services.GravatarService +import com.gravatar.app.usercomponent.domain.repository.AuthRepository + +internal class DeleteUserProfileUseCase( + private val gravatarService: GravatarService, + private val authRepository: AuthRepository, + private val logout: Logout +) : DeleteUserProfile { + + override suspend fun invoke(): Result { + val token = authRepository.getToken() + return if (token != null) { + gravatarService.deleteProfile(token) + .onSuccess { + logout() + } + } else { + Result.failure(IllegalStateException("User is not logged in")) + } + } +} + +interface DeleteUserProfile { + suspend operator fun invoke(): Result +} diff --git a/userComponent/src/test/kotlin/com/gravatar/app/usercomponent/domain/usecase/DeleteUserProfileUseCaseTest.kt b/userComponent/src/test/kotlin/com/gravatar/app/usercomponent/domain/usecase/DeleteUserProfileUseCaseTest.kt new file mode 100644 index 00000000..986385d4 --- /dev/null +++ b/userComponent/src/test/kotlin/com/gravatar/app/usercomponent/domain/usecase/DeleteUserProfileUseCaseTest.kt @@ -0,0 +1,95 @@ +package com.gravatar.app.usercomponent.domain.usecase + +import com.gravatar.app.services.GravatarService +import com.gravatar.app.testUtils.CoroutineTestRule +import com.gravatar.app.usercomponent.domain.repository.AuthRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DeleteUserProfileUseCaseTest { + private val testDispatcher = StandardTestDispatcher() + + @get:Rule + var coroutineTestRule = CoroutineTestRule(testDispatcher) + + private val gravatarService: GravatarService = mockk() + private val authRepository: AuthRepository = mockk() + private val logout: Logout = mockk() + private lateinit var deleteUserProfileUseCase: DeleteUserProfileUseCase + + private val testToken = "test-token" + + @Before + fun setup() { + deleteUserProfileUseCase = DeleteUserProfileUseCase( + gravatarService = gravatarService, + authRepository = authRepository, + logout = logout + ) + + // Default mock behavior + coEvery { logout.invoke() } returns Unit + } + + @Test + fun `invoke should return success when user is logged in and profile deletion succeeds`() = runTest { + // Given + coEvery { authRepository.getToken() } returns testToken + coEvery { gravatarService.deleteProfile(testToken) } returns Result.success(Unit) + + // When + val result = deleteUserProfileUseCase.invoke() + + // Then + assertTrue(result.isSuccess) + coVerify { authRepository.getToken() } + coVerify { gravatarService.deleteProfile(testToken) } + coVerify { logout.invoke() } + } + + @Test + fun `invoke should return failure when user is not logged in`() = runTest { + // Given + coEvery { authRepository.getToken() } returns null + + // When + val result = deleteUserProfileUseCase.invoke() + + // Then + assertTrue(result.isFailure) + val exception = result.exceptionOrNull() + assertTrue(exception is IllegalStateException) + assertEquals("User is not logged in", exception?.message) + coVerify { authRepository.getToken() } + coVerify(exactly = 0) { gravatarService.deleteProfile(any()) } + coVerify(exactly = 0) { logout.invoke() } + } + + @Test + fun `invoke should return failure when profile deletion fails`() = runTest { + // Given + val testException = Exception("Service error") + coEvery { authRepository.getToken() } returns testToken + coEvery { gravatarService.deleteProfile(testToken) } returns Result.failure(testException) + + // When + val result = deleteUserProfileUseCase.invoke() + + // Then + assertTrue(result.isFailure) + assertEquals(testException, result.exceptionOrNull()) + coVerify { authRepository.getToken() } + coVerify { gravatarService.deleteProfile(testToken) } + coVerify(exactly = 0) { logout.invoke() } + } +}