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() }
+ }
+}