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> }