diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 64ce68c72a..5641a5a7db 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,5 +1,6 @@
plugins {
id("godtools.application-conventions")
+ id("kotlin-parcelize")
alias(libs.plugins.firebase.appdistribution)
alias(libs.plugins.firebase.crashlytics)
alias(libs.plugins.firebase.perf)
@@ -205,6 +206,8 @@ dependencies {
implementation(libs.godtoolsShared.common)
api(libs.eventbus)
+ implementation(libs.circuit.codegen.annotations)
+ implementation(libs.circuit.foundation)
implementation(libs.coil.compose)
implementation(libs.compose.reorderable)
implementation(libs.hilt)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f656ba9274..13850eba64 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -150,6 +150,10 @@
android:name=".ui.account.AccountActivity"
android:parentActivityName=".ui.dashboard.DashboardActivity" />
+
+
+
+ @Multibinds
+ abstract fun uiFactories(): Set
+
+ companion object {
+ @Provides
+ @Reusable
+ fun circuit(
+ presenterFactories: Set<@JvmSuppressWildcards Presenter.Factory>,
+ uiFactories: Set<@JvmSuppressWildcards Ui.Factory>
+ ) = Circuit.Builder()
+ .addPresenterFactories(presenterFactories)
+ .addUiFactories(uiFactories)
+ .build()
+ }
+}
diff --git a/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountActivity.kt b/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountActivity.kt
new file mode 100644
index 0000000000..9524214561
--- /dev/null
+++ b/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountActivity.kt
@@ -0,0 +1,36 @@
+package org.cru.godtools.ui.account.delete
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import com.slack.circuit.backstack.rememberSaveableBackStack
+import com.slack.circuit.foundation.Circuit
+import com.slack.circuit.foundation.CircuitCompositionLocals
+import com.slack.circuit.foundation.NavigableCircuitContent
+import com.slack.circuit.foundation.rememberCircuitNavigator
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+import org.cru.godtools.base.ui.activity.BaseActivity
+import org.cru.godtools.base.ui.theme.GodToolsTheme
+
+fun Context.startDeleteAccountActivity() = startActivity(Intent(this, DeleteAccountActivity::class.java))
+
+@AndroidEntryPoint
+class DeleteAccountActivity : BaseActivity() {
+ @Inject
+ lateinit var circuit: Circuit
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ CircuitCompositionLocals(circuit) {
+ GodToolsTheme {
+ val backStack = rememberSaveableBackStack { push(DeleteAccountScreen) }
+ val navigator = rememberCircuitNavigator(backStack)
+ NavigableCircuitContent(navigator, backStack)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountLayout+Preview.kt b/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountLayout+Preview.kt
new file mode 100644
index 0000000000..1790ad0ed6
--- /dev/null
+++ b/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountLayout+Preview.kt
@@ -0,0 +1,23 @@
+package org.cru.godtools.ui.account.delete
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import org.cru.godtools.base.ui.theme.GodToolsTheme
+
+@Preview
+@Composable
+private fun DeleteAccountLayoutDisplayPreview() {
+ GodToolsTheme { DeleteAccountLayout(DeleteAccountScreen.State.Display { }) }
+}
+
+@Preview
+@Composable
+private fun DeleteAccountLayoutDeletingPreview() {
+ GodToolsTheme { DeleteAccountLayout(DeleteAccountScreen.State.Deleting { }) }
+}
+
+@Preview
+@Composable
+private fun DeleteAccountLayoutErrorPreview() {
+ GodToolsTheme { DeleteAccountLayout(DeleteAccountScreen.State.Error { }) }
+}
diff --git a/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountLayout.kt b/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountLayout.kt
new file mode 100644
index 0000000000..f68e6165f1
--- /dev/null
+++ b/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountLayout.kt
@@ -0,0 +1,139 @@
+package org.cru.godtools.ui.account.delete
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.slack.circuit.codegen.annotations.CircuitInject
+import dagger.hilt.components.SingletonComponent
+import org.ccci.gto.android.common.androidx.compose.foundation.layout.padding
+import org.cru.godtools.R
+import org.cru.godtools.ui.account.delete.DeleteAccountScreen.Event
+import org.cru.godtools.ui.account.delete.DeleteAccountScreen.State
+
+private val MARGIN_HORIZONTAL = 32.dp
+
+internal const val TEST_TAG_ICON_CLOSE = "icon_close"
+internal const val TEST_TAG_BUTTON_DELETE = "button_delete"
+internal const val TEST_TAG_BUTTON_CANCEL = "button_cancel"
+internal const val TEST_TAG_ERROR_DIALOG = "error_dialog"
+internal const val TEST_TAG_ERROR_DIALOG_BUTTON_CONFIRM = "error_dialog_button_confirm"
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+@CircuitInject(DeleteAccountScreen::class, SingletonComponent::class)
+fun DeleteAccountLayout(state: State, modifier: Modifier = Modifier) {
+ DeleteAccountError(state)
+
+ Scaffold(modifier = modifier) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .padding(it)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ TopAppBar(
+ title = {},
+ navigationIcon = {
+ IconButton(
+ onClick = { state.eventSink(Event.Close) },
+ modifier = Modifier.testTag(TEST_TAG_ICON_CLOSE)
+ ) {
+ Icon(Icons.Default.Close, null)
+ }
+ },
+ )
+
+ Spacer(Modifier.weight(1f))
+ Image(
+ painterResource(R.drawable.banner_account_login),
+ contentDescription = null,
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.weight(1f))
+
+ Text(
+ stringResource(R.string.account_delete_heading),
+ style = MaterialTheme.typography.displayMedium,
+ modifier = Modifier
+ .padding(horizontal = MARGIN_HORIZONTAL)
+ .align(Alignment.Start)
+ )
+ Text(
+ stringResource(R.string.account_delete_description),
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(horizontal = MARGIN_HORIZONTAL, top = 8.dp, bottom = 32.dp)
+ )
+
+ val actionsEnabled = state !is State.Deleting && state !is State.Error
+ OutlinedButton(
+ enabled = actionsEnabled,
+ onClick = { state.eventSink(Event.DeleteAccount) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = MARGIN_HORIZONTAL)
+ .testTag(TEST_TAG_BUTTON_DELETE)
+ ) {
+ Text(stringResource(R.string.account_delete_action_delete))
+ }
+ Button(
+ enabled = actionsEnabled,
+ onClick = { state.eventSink(Event.Close) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = MARGIN_HORIZONTAL)
+ .testTag(TEST_TAG_BUTTON_CANCEL)
+ ) {
+ Text(stringResource(R.string.account_delete_action_cancel))
+ }
+ Spacer(Modifier.height(32.dp))
+ }
+ }
+}
+
+@Composable
+private fun DeleteAccountError(state: State) {
+ if (state is State.Error) {
+ AlertDialog(
+ text = { Text(stringResource(R.string.account_delete_error)) },
+ confirmButton = {
+ TextButton(
+ onClick = { state.eventSink(Event.ClearError) },
+ modifier = Modifier.testTag(TEST_TAG_ERROR_DIALOG_BUTTON_CONFIRM),
+ ) {
+ Text(stringResource(R.string.account_delete_error_dismiss))
+ }
+ },
+ onDismissRequest = { state.eventSink(Event.ClearError) },
+ modifier = Modifier.testTag(TEST_TAG_ERROR_DIALOG),
+ )
+ }
+}
diff --git a/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountModule.kt b/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountModule.kt
new file mode 100644
index 0000000000..376bf0496d
--- /dev/null
+++ b/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountModule.kt
@@ -0,0 +1,38 @@
+package org.cru.godtools.ui.account.delete
+
+import com.slack.circuit.runtime.presenter.Presenter
+import com.slack.circuit.runtime.ui.Ui
+import com.slack.circuit.runtime.ui.ui
+import dagger.Module
+import dagger.Provides
+import dagger.Reusable
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+
+// TODO: switch to codegen for this once https://github.com/slackhq/circuit/pull/963 is released
+@Module
+@InstallIn(SingletonComponent::class)
+object DeleteAccountModule {
+ @Provides
+ @Reusable
+ @IntoSet
+ fun presenterFactory(factory: DeleteAccountPresenter.Factory) = Presenter.Factory { screen, navigator, _ ->
+ when (screen) {
+ DeleteAccountScreen -> factory.create(navigator = navigator)
+ else -> null
+ }
+ }
+
+ @Provides
+ @Reusable
+ @IntoSet
+ fun uiFactory() = Ui.Factory { screen, _ ->
+ when (screen) {
+ DeleteAccountScreen -> ui { state, modifier ->
+ DeleteAccountLayout(state = state, modifier = modifier)
+ }
+ else -> null
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountPresenter.kt b/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountPresenter.kt
new file mode 100644
index 0000000000..f3531a3326
--- /dev/null
+++ b/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountPresenter.kt
@@ -0,0 +1,64 @@
+package org.cru.godtools.ui.account.delete
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import com.slack.circuit.codegen.annotations.CircuitInject
+import com.slack.circuit.retained.rememberRetained
+import com.slack.circuit.runtime.Navigator
+import com.slack.circuit.runtime.presenter.Presenter
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.launch
+import org.cru.godtools.account.GodToolsAccountManager
+import org.cru.godtools.ui.account.delete.DeleteAccountScreen.Event
+
+class DeleteAccountPresenter @AssistedInject constructor(
+ @Assisted private val navigator: Navigator,
+ private val accountManager: GodToolsAccountManager,
+) : Presenter {
+ @Composable
+ override fun present(): DeleteAccountScreen.State {
+ val coroutineScope = rememberCoroutineScope()
+
+ var deleting by remember { mutableStateOf(false) }
+ var error by rememberRetained { mutableStateOf(false) }
+
+ val eventSink: (Event) -> Unit = remember {
+ {
+ when (it) {
+ Event.DeleteAccount -> {
+ deleting = true
+ coroutineScope.launch {
+ if (accountManager.deleteAccount()) {
+ navigator.pop()
+ } else {
+ error = true
+ deleting = false
+ }
+ }
+ }
+ Event.ClearError -> error = false
+ Event.Close -> navigator.pop()
+ }
+ }
+ }
+
+ return when {
+ error -> DeleteAccountScreen.State.Error(eventSink)
+ deleting -> DeleteAccountScreen.State.Deleting(eventSink)
+ else -> DeleteAccountScreen.State.Display(eventSink)
+ }
+ }
+
+ @AssistedFactory
+ @CircuitInject(DeleteAccountScreen::class, SingletonComponent::class)
+ interface Factory {
+ fun create(navigator: Navigator): DeleteAccountPresenter
+ }
+}
diff --git a/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountScreen.kt b/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountScreen.kt
new file mode 100644
index 0000000000..46765eb08c
--- /dev/null
+++ b/app/src/main/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountScreen.kt
@@ -0,0 +1,23 @@
+package org.cru.godtools.ui.account.delete
+
+import com.slack.circuit.runtime.CircuitUiEvent
+import com.slack.circuit.runtime.CircuitUiState
+import com.slack.circuit.runtime.screen.Screen
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data object DeleteAccountScreen : Screen {
+ sealed class State : CircuitUiState {
+ data class Display(override val eventSink: (Event) -> Unit) : State()
+ data class Deleting(override val eventSink: (Event) -> Unit) : State()
+ data class Error(override val eventSink: (Event) -> Unit) : State()
+
+ abstract val eventSink: (Event) -> Unit
+ }
+
+ sealed class Event : CircuitUiEvent {
+ data object DeleteAccount : Event()
+ data object ClearError : Event()
+ data object Close : Event()
+ }
+}
diff --git a/app/src/main/kotlin/org/cru/godtools/ui/drawer/DrawerMenuLayout.kt b/app/src/main/kotlin/org/cru/godtools/ui/drawer/DrawerMenuLayout.kt
index f338149af2..11c785d4f5 100644
--- a/app/src/main/kotlin/org/cru/godtools/ui/drawer/DrawerMenuLayout.kt
+++ b/app/src/main/kotlin/org/cru/godtools/ui/drawer/DrawerMenuLayout.kt
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.outlined.Login
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.PersonAdd
+import androidx.compose.material.icons.outlined.PersonRemove
import androidx.compose.material.icons.outlined.Policy
import androidx.compose.material.icons.outlined.RateReview
import androidx.compose.material.icons.outlined.School
@@ -63,6 +64,7 @@ import org.cru.godtools.shared.analytics.AnalyticsActionNames
import org.cru.godtools.shared.analytics.AnalyticsScreenNames
import org.cru.godtools.tutorial.PageSet
import org.cru.godtools.tutorial.startTutorialActivity
+import org.cru.godtools.ui.account.delete.startDeleteAccountActivity
import org.cru.godtools.ui.account.startAccountActivity
import org.cru.godtools.ui.languages.startLanguageSettingsActivity
import org.cru.godtools.ui.login.startLoginActivity
@@ -173,6 +175,15 @@ fun DrawerContentLayout(
dismissDrawer()
}
)
+ NavigationDrawerItem(
+ icon = { Icon(Icons.Outlined.PersonRemove, null) },
+ label = { Text(stringResource(R.string.menu_account_delete)) },
+ selected = false,
+ onClick = {
+ context.startDeleteAccountActivity()
+ dismissDrawer()
+ },
+ )
}
Divider(modifier = Modifier.padding(horizontal = 16.dp))
}
diff --git a/app/src/main/res/values/strings_account.xml b/app/src/main/res/values/strings_account.xml
index 490b6fe52b..1b149c6ad1 100644
--- a/app/src/main/res/values/strings_account.xml
+++ b/app/src/main/res/values/strings_account.xml
@@ -14,6 +14,16 @@
Error logging in.
OK
+
+
+ Delete Account
+ Don\'t go so soon
+ If you delete your account you won\'t be able to access your favorited tools or account activity.
+ Delete my account
+ Keep my account
+ There was an error deleting your account. Make sure you are online or try again later.
+ OK
+
Activity
diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountLayoutTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountLayoutTest.kt
new file mode 100644
index 0000000000..bc35719918
--- /dev/null
+++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountLayoutTest.kt
@@ -0,0 +1,167 @@
+package org.cru.godtools.ui.account.delete
+
+import android.app.Application
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.hasAnyAncestor
+import androidx.compose.ui.test.hasAnyDescendant
+import androidx.compose.ui.test.hasTestTag
+import androidx.compose.ui.test.isDialog
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.slack.circuit.test.TestEventSink
+import kotlin.test.Ignore
+import kotlin.test.Test
+import org.cru.godtools.ui.account.delete.DeleteAccountScreen.Event
+import org.cru.godtools.ui.account.delete.DeleteAccountScreen.State
+import org.junit.Rule
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+@Config(application = Application::class)
+class DeleteAccountLayoutTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val events = TestEventSink()
+
+ // region State Display
+ @Test
+ fun `State Display - Action - Close Icon`() {
+ composeTestRule.run {
+ setContent { DeleteAccountLayout(State.Display(events)) }
+ onNodeWithTag(TEST_TAG_ICON_CLOSE)
+ .assertIsEnabled()
+ .performClick()
+ }
+
+ events.assertEvent(Event.Close)
+ }
+
+ @Test
+ fun `State Display - Action - Delete Button`() {
+ composeTestRule.run {
+ setContent { DeleteAccountLayout(State.Display(events)) }
+ onNodeWithTag(TEST_TAG_BUTTON_DELETE)
+ .assertIsEnabled()
+ .performClick()
+ }
+
+ events.assertEvent(Event.DeleteAccount)
+ }
+
+ @Test
+ fun `State Display - Action - Cancel Button`() {
+ composeTestRule.run {
+ setContent { DeleteAccountLayout(State.Display(events)) }
+ onNodeWithTag(TEST_TAG_BUTTON_CANCEL)
+ .assertIsEnabled()
+ .performClick()
+ }
+
+ events.assertEvent(Event.Close)
+ }
+
+ @Test
+ fun `State Display - No Error Dialog`() {
+ composeTestRule.run {
+ setContent { DeleteAccountLayout(State.Display(events)) }
+ onNode(isDialog()).assertDoesNotExist()
+ }
+
+ events.assertNoEvents()
+ }
+ // endregion State Display
+
+ // region State Deleting
+ @Test
+ fun `State Deleting - Action - Close Icon`() {
+ composeTestRule.run {
+ setContent { DeleteAccountLayout(State.Deleting(events)) }
+ onNodeWithTag(TEST_TAG_ICON_CLOSE)
+ .assertIsEnabled()
+ .performClick()
+ }
+
+ events.assertEvent(Event.Close)
+ }
+
+ @Test
+ fun `State Deleting - Action - Disabled Delete & Cancel Buttons`() {
+ composeTestRule.run {
+ setContent { DeleteAccountLayout(State.Deleting(events)) }
+ onNodeWithTag(TEST_TAG_BUTTON_DELETE).assertIsNotEnabled()
+ onNodeWithTag(TEST_TAG_BUTTON_CANCEL).assertIsNotEnabled()
+ }
+
+ events.assertNoEvents()
+ }
+ // endregion State Deleting
+
+ // region State Error
+ @Test
+ fun `State Error - Action - Close Icon`() {
+ composeTestRule.run {
+ setContent { DeleteAccountLayout(State.Error(events)) }
+ onNodeWithTag(TEST_TAG_ICON_CLOSE)
+ .assertIsEnabled()
+ .performClick()
+ }
+
+ events.assertEvent(Event.Close)
+ }
+
+ @Test
+ fun `State Error - Action - Disabled Delete & Cancel Buttons`() {
+ composeTestRule.run {
+ setContent { DeleteAccountLayout(State.Error(events)) }
+ onNodeWithTag(TEST_TAG_BUTTON_DELETE).assertIsNotEnabled()
+ onNodeWithTag(TEST_TAG_BUTTON_CANCEL).assertIsNotEnabled()
+ }
+
+ events.assertNoEvents()
+ }
+
+ @Test
+ fun `State Error - Error Dialog`() {
+ composeTestRule.run {
+ setContent { DeleteAccountLayout(State.Error(events)) }
+ onNode(isDialog() and hasAnyDescendant(hasTestTag(TEST_TAG_ERROR_DIALOG)))
+ .assertExists()
+ }
+
+ events.assertNoEvents()
+ }
+
+ @Test
+ fun `State Error - Error Dialog - Confirm Button`() {
+ composeTestRule.run {
+ setContent { DeleteAccountLayout(State.Error(events)) }
+ onNode(
+ hasAnyAncestor(hasTestTag(TEST_TAG_ERROR_DIALOG)) and
+ hasTestTag(TEST_TAG_ERROR_DIALOG_BUTTON_CONFIRM)
+ )
+ .assertIsEnabled()
+ .performClick()
+ }
+
+ events.assertEvent(Event.ClearError)
+ }
+
+ @Test
+ @Ignore("It's not currently possible to dismiss the dialog from a test.")
+ fun `State Error - Error Dialog - Dismiss Dialog`() {
+ composeTestRule.run {
+ setContent { DeleteAccountLayout(State.Error(events)) }
+ onNode(isDialog() and hasTestTag(TEST_TAG_ERROR_DIALOG)).assertExists()
+ // TODO: Dismiss the dialog
+ // see: https://issuetracker.google.com/issues/229759201
+ }
+
+ events.assertEvent(Event.ClearError)
+ }
+ // endregion State Error
+}
diff --git a/app/src/testDebug/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountPresenterTest.kt b/app/src/testDebug/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountPresenterTest.kt
new file mode 100644
index 0000000000..cdb742fce5
--- /dev/null
+++ b/app/src/testDebug/kotlin/org/cru/godtools/ui/account/delete/DeleteAccountPresenterTest.kt
@@ -0,0 +1,105 @@
+package org.cru.godtools.ui.account.delete
+
+import android.app.Application
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.slack.circuit.test.FakeNavigator
+import com.slack.circuit.test.test
+import io.mockk.Called
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.confirmVerified
+import io.mockk.mockk
+import kotlin.test.Test
+import kotlin.test.assertIs
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.test.runTest
+import org.cru.godtools.account.GodToolsAccountManager
+import org.cru.godtools.ui.account.delete.DeleteAccountScreen.Event
+import org.cru.godtools.ui.account.delete.DeleteAccountScreen.State
+import org.junit.Rule
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+@Config(application = Application::class)
+class DeleteAccountPresenterTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val deleteAccountResponse = Channel()
+
+ private val accountManager: GodToolsAccountManager = mockk {
+ coEvery { deleteAccount() } coAnswers { deleteAccountResponse.receive() }
+ }
+ private val navigator = FakeNavigator()
+
+ private val presenter = DeleteAccountPresenter(navigator, accountManager)
+
+ @Test
+ fun `Delete Account - succeeds`() = runTest {
+ presenter.test {
+ assertIs(awaitItem())
+ .eventSink(Event.DeleteAccount)
+
+ assertIs(awaitItem())
+ deleteAccountResponse.send(true)
+ coVerify { accountManager.deleteAccount() }
+ navigator.awaitPop()
+
+ cancelAndIgnoreRemainingEvents()
+ }
+
+ confirmVerified(accountManager)
+ }
+
+ @Test
+ fun `Delete Account - fails`() = runTest {
+ presenter.test {
+ assertIs(awaitItem())
+ .eventSink(Event.DeleteAccount)
+
+ assertIs(awaitItem())
+ deleteAccountResponse.send(false)
+ coVerify { accountManager.deleteAccount() }
+
+ assertIs(expectMostRecentItem())
+ .eventSink(Event.ClearError)
+
+ assertIs(awaitItem())
+ }
+
+ confirmVerified(accountManager)
+ }
+
+ @Test
+ fun `Cancel Delete Account`() = runTest {
+ coEvery { accountManager.deleteAccount() } returns true
+
+ presenter.test {
+ assertIs(awaitItem())
+ .eventSink(Event.Close)
+ navigator.awaitPop()
+ coVerify { accountManager wasNot Called }
+ }
+
+ confirmVerified(accountManager)
+ }
+
+ @Test
+ fun `Cancel Delete Account - While Deleting`() = runTest {
+ presenter.test {
+ assertIs(awaitItem())
+ .eventSink(Event.DeleteAccount)
+
+ assertIs(awaitItem())
+ .eventSink(Event.Close)
+ coVerify { accountManager.deleteAccount() }
+ navigator.awaitPop()
+
+ cancelAndIgnoreRemainingEvents()
+ }
+
+ confirmVerified(accountManager)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5bbcbaccfb..a9a7f6efc7 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -10,6 +10,7 @@ androidx-lifecycle = "2.6.2"
androidx-room = "2.6.0"
androidx-viewpager2 = "1.0.0"
androidx-work = "2.8.1"
+circuit = "0.16.0"
dagger = "2.48.1"
eventbus = "3.3.1"
facebook = "16.2.0"
@@ -77,6 +78,9 @@ androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref =
androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
androidx-work-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" }
appsflyer = "com.appsflyer:af-android-sdk:6.12.4"
+circuit-codegen-annotations = { module = "com.slack.circuit:circuit-codegen-annotations", version.ref = "circuit" }
+circuit-foundation = { module = "com.slack.circuit:circuit-foundation", version.ref = "circuit" }
+circuit-test = { module = "com.slack.circuit:circuit-test", version.ref = "circuit" }
coil-compose = "io.coil-kt:coil-compose:2.5.0"
compose-reorderable = "org.burnoutcrew.composereorderable:reorderable:0.9.6"
dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" }
@@ -212,7 +216,7 @@ ktlint = { module = "com.pinterest.ktlint:ktlint-cli", version.ref = "ktlint" }
[bundles]
androidx-compose = ["androidx-compose-runtime", "androidx-compose-ui", "androidx-compose-ui-tooling-preview"]
androidx-compose-debug = ["androidx-compose-ui-test-manifest", "androidx-compose-ui-tooling"]
-androidx-compose-testing = ["androidx-compose-ui-test"]
+androidx-compose-testing = ["androidx-compose-ui-test", "circuit-test"]
common = ["kotlin-stdlib", "timber"]
test-framework = ["junit", "kotlin-test", "androidx-test-junit", "mockk", "robolectric"]
diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/GodToolsAccountManager.kt b/library/account/src/main/kotlin/org/cru/godtools/account/GodToolsAccountManager.kt
index 2236d2e01b..3ab70641ba 100644
--- a/library/account/src/main/kotlin/org/cru/godtools/account/GodToolsAccountManager.kt
+++ b/library/account/src/main/kotlin/org/cru/godtools/account/GodToolsAccountManager.kt
@@ -5,6 +5,8 @@ import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.core.app.ActivityOptionsCompat
+import dagger.Lazy
+import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
@@ -23,17 +25,21 @@ import kotlinx.coroutines.launch
import org.ccci.gto.android.common.Ordered
import org.cru.godtools.account.provider.AccountProvider
import org.cru.godtools.account.provider.AuthenticationException
+import org.cru.godtools.api.UserApi
@Singleton
@OptIn(ExperimentalCoroutinesApi::class)
class GodToolsAccountManager @VisibleForTesting internal constructor(
@get:VisibleForTesting
internal val providers: List,
+ private val userApi: Lazy,
coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob()),
) {
@Inject
- internal constructor(providers: Set<@JvmSuppressWildcards AccountProvider>) :
- this(providers.sortedWith(Ordered.COMPARATOR))
+ internal constructor(
+ providers: Set<@JvmSuppressWildcards AccountProvider>,
+ userApi: Lazy,
+ ) : this(providers.sortedWith(Ordered.COMPARATOR), userApi)
// region Active Provider
@VisibleForTesting
@@ -98,6 +104,14 @@ class GodToolsAccountManager @VisibleForTesting internal constructor(
// trigger a logout for any provider we happen to be logged into
providers.forEach { launch { it.logout() } }
}
+
+ suspend fun deleteAccount() = try {
+ userApi.get().deleteUser()
+ .also { if (it.isSuccessful) logout() }
+ .isSuccessful
+ } catch (_: IOException) {
+ false
+ }
// endregion Login/Logout
internal suspend fun authenticateWithMobileContentApi() = activeProvider?.authenticateWithMobileContentApi(false)
diff --git a/library/account/src/test/kotlin/org/cru/godtools/account/GodToolsAccountManagerTest.kt b/library/account/src/test/kotlin/org/cru/godtools/account/GodToolsAccountManagerTest.kt
index 2b0721bbdf..f0e22c519a 100644
--- a/library/account/src/test/kotlin/org/cru/godtools/account/GodToolsAccountManagerTest.kt
+++ b/library/account/src/test/kotlin/org/cru/godtools/account/GodToolsAccountManagerTest.kt
@@ -1,10 +1,14 @@
package org.cru.godtools.account
import app.cash.turbine.test
+import io.mockk.Called
import io.mockk.coEvery
import io.mockk.coVerify
+import io.mockk.coVerifySequence
import io.mockk.every
+import io.mockk.excludeRecords
import io.mockk.mockk
+import java.io.IOException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -16,7 +20,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.ccci.gto.android.common.jsonapi.model.JsonApiObject
import org.cru.godtools.account.provider.AccountProvider
+import org.cru.godtools.api.UserApi
+import org.cru.godtools.model.User
+import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class GodToolsAccountManagerTest {
@@ -27,22 +36,28 @@ class GodToolsAccountManagerTest {
every { order } returns 1
coEvery { isAuthenticated } answers { provider1Authenticated.value }
every { isAuthenticatedFlow() } returns provider1Authenticated
+
+ excludeRecords { isAuthenticatedFlow() }
}
private val provider2 = mockk(relaxed = true) {
every { order } returns 2
coEvery { isAuthenticated } answers { provider2Authenticated.value }
every { isAuthenticatedFlow() } returns provider2Authenticated
+
+ excludeRecords { isAuthenticatedFlow() }
}
private val testScope = TestScope()
+ private val userApi: UserApi = mockk()
private val manager = GodToolsAccountManager(
providers = listOf(provider1, provider2),
- coroutineScope = testScope.backgroundScope
+ userApi = { userApi },
+ coroutineScope = testScope.backgroundScope,
)
@Test
fun verifyInjectedProvidersSorted() {
- val manager = GodToolsAccountManager(setOf(provider2, provider1))
+ val manager = GodToolsAccountManager(providers = setOf(provider2, provider1), userApi = { userApi })
assertEquals(listOf(provider1, provider2), manager.providers)
}
@@ -115,4 +130,46 @@ class GodToolsAccountManagerTest {
provider2.logout()
}
}
+
+ // region deleteAccount()
+ @Test
+ fun `deleteAccount()`() = testScope.runTest {
+ coEvery { userApi.deleteUser() } returns Response.success>(204, null)
+
+ assertTrue(manager.deleteAccount())
+ coVerifySequence {
+ userApi.deleteUser()
+ provider1.logout()
+ provider2.logout()
+ }
+ }
+
+ @Test
+ fun `deleteAccount() - not authenticated`() = testScope.runTest {
+ coEvery { userApi.deleteUser() } returns Response.error(401, "".toResponseBody())
+
+ assertFalse(manager.deleteAccount())
+ coVerifySequence {
+ userApi.deleteUser()
+
+ // deleting the user failed, so don't log the user out
+ provider1 wasNot Called
+ provider2 wasNot Called
+ }
+ }
+
+ @Test
+ fun `deleteAccount() - IOException`() = testScope.runTest {
+ coEvery { userApi.deleteUser() } throws IOException()
+
+ assertFalse(manager.deleteAccount())
+ coVerifySequence {
+ userApi.deleteUser()
+
+ // deleting the user failed, so don't log the user out
+ provider1 wasNot Called
+ provider2 wasNot Called
+ }
+ }
+ // endregion deleteAccount()
}
diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt b/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt
index 7c8209cc5c..4082806155 100644
--- a/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt
+++ b/library/api/src/main/kotlin/org/cru/godtools/api/UserApi.kt
@@ -6,6 +6,7 @@ import org.ccci.gto.android.common.jsonapi.retrofit2.model.JsonApiRetrofitObject
import org.cru.godtools.model.User
import retrofit2.Response
import retrofit2.http.Body
+import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.QueryMap
@@ -18,4 +19,7 @@ interface UserApi {
@PATCH(PATH_USER)
suspend fun updateUser(@Body user: JsonApiRetrofitObject): Response>
+
+ @DELETE(PATH_USER)
+ suspend fun deleteUser(): Response>
}