Add delete account feature to mobile app

- DELETE /api/auth/account/ API call in AuthApi + APILayer
- authProvider field on User model for email vs social auth detection
- DeleteAccountDialog with password (email) or "type DELETE" (social) confirmation
- Red "Delete Account" card on ProfileScreen
- Navigation wired in App.kt (clears data, returns to login)
- 10 i18n strings in strings.xml
- ViewModel unit tests for delete account state
This commit is contained in:
Trey T
2026-03-26 10:41:17 -05:00
parent 2e5dbaea50
commit af45588503
9 changed files with 352 additions and 0 deletions

View File

@@ -556,6 +556,18 @@
<string name="profile_app_name">honeyDue</string> <string name="profile_app_name">honeyDue</string>
<string name="profile_edit_profile">Edit Profile</string> <string name="profile_edit_profile">Edit Profile</string>
<!-- Delete Account -->
<string name="delete_account_title">Delete Account</string>
<string name="delete_account_subtitle">Permanently delete your account</string>
<string name="delete_account_warning">This action is permanent and cannot be undone. All your data will be deleted.</string>
<string name="delete_account_shared_warning">Any residences you own that are shared with other users will also be deleted.</string>
<string name="delete_account_confirm_password">Enter your password to confirm</string>
<string name="delete_account_confirm_type">Type DELETE to confirm</string>
<string name="delete_account_button">Delete My Account</string>
<string name="delete_account_cancel">Cancel</string>
<string name="delete_account_success">Account deleted successfully</string>
<string name="delete_account_failed">Failed to delete account</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="settings_notifications">Notification Preferences</string> <string name="settings_notifications">Notification Preferences</string>

View File

@@ -665,6 +665,15 @@ fun App(
popUpTo<ProfileRoute> { inclusive = true } popUpTo<ProfileRoute> { inclusive = true }
} }
}, },
onAccountDeleted = {
// Clear token and lookups on account deletion
DataManager.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
popUpTo<ProfileRoute> { inclusive = true }
}
},
onNavigateToNotificationPreferences = { onNavigateToNotificationPreferences = {
navController.navigate(NotificationPreferencesRoute) navController.navigate(NotificationPreferencesRoute)
}, },

View File

@@ -16,6 +16,7 @@ data class User(
@SerialName("is_active") val isActive: Boolean = true, @SerialName("is_active") val isActive: Boolean = true,
@SerialName("date_joined") val dateJoined: String, @SerialName("date_joined") val dateJoined: String,
@SerialName("last_login") val lastLogin: String? = null, @SerialName("last_login") val lastLogin: String? = null,
@SerialName("auth_provider") val authProvider: String? = null,
// Profile is included in CurrentUserResponse (/auth/me) // Profile is included in CurrentUserResponse (/auth/me)
val profile: UserProfile? = null, val profile: UserProfile? = null,
// Verified is returned directly in LoginResponse, and also in profile for CurrentUserResponse // Verified is returned directly in LoginResponse, and also in profile for CurrentUserResponse
@@ -206,3 +207,12 @@ data class GoogleSignInResponse(
val user: User, val user: User,
@SerialName("is_new_user") val isNewUser: Boolean @SerialName("is_new_user") val isNewUser: Boolean
) )
/**
* Delete account request matching Go API
*/
@Serializable
data class DeleteAccountRequest(
val password: String? = null,
val confirmation: String? = null
)

View File

@@ -1333,6 +1333,18 @@ object APILayer {
return result return result
} }
suspend fun deleteAccount(password: String? = null, confirmation: String? = null): ApiResult<Unit> {
val token = getToken() ?: return ApiResult.Error("Not authenticated", 401)
val result = authApi.deleteAccount(token, DeleteAccountRequest(password = password, confirmation = confirmation))
// Clear DataManager on successful deletion
if (result is ApiResult.Success) {
DataManager.clear()
}
return result
}
// ==================== Notification Operations ==================== // ==================== Notification Operations ====================
suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> { suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> {

View File

@@ -228,6 +228,26 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
} }
} }
// Delete Account
suspend fun deleteAccount(token: String, request: DeleteAccountRequest): ApiResult<Unit> {
return try {
val response = client.delete("$baseUrl/auth/account/") {
header("Authorization", "Token $token")
contentType(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else {
val errorMessage = ErrorParser.parseError(response)
ApiResult.Error(errorMessage, response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
// Google Sign In // Google Sign In
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> { suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
return try { return try {

View File

@@ -0,0 +1,166 @@
package com.tt.honeyDue.ui.components.dialogs
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.tt.honeyDue.ui.theme.AppRadius
import com.tt.honeyDue.ui.theme.AppSpacing
import honeydue.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
/**
* Dialog for confirming account deletion.
*
* For email auth users: shows a password field for confirmation.
* For social auth users: shows a "type DELETE to confirm" field.
*
* @param isSocialAuthUser Whether the user signed in via social auth (Apple/Google)
* @param onConfirm Callback with password (for email users) or confirmation text (for social users)
* @param onDismiss Callback when dialog is dismissed
* @param isLoading Whether the delete request is in progress
*/
@Composable
fun DeleteAccountDialog(
isSocialAuthUser: Boolean,
onConfirm: (password: String?, confirmation: String?) -> Unit,
onDismiss: () -> Unit,
isLoading: Boolean
) {
var inputValue by remember { mutableStateOf("") }
val isConfirmEnabled = if (isSocialAuthUser) {
inputValue.equals("DELETE", ignoreCase = false)
} else {
inputValue.isNotBlank()
}
Dialog(onDismissRequest = { if (!isLoading) onDismiss() }) {
Card(
shape = RoundedCornerShape(AppRadius.lg),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
),
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg)
) {
Column(
modifier = Modifier.padding(AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
// Warning Icon
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp)
)
// Title
Text(
text = stringResource(Res.string.delete_account_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
// Warning text
Text(
text = stringResource(Res.string.delete_account_warning),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Shared residence warning
Text(
text = stringResource(Res.string.delete_account_shared_warning),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(AppSpacing.sm))
// Input field
if (isSocialAuthUser) {
OutlinedTextField(
value = inputValue,
onValueChange = { inputValue = it },
label = { Text(stringResource(Res.string.delete_account_confirm_type)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isLoading,
shape = RoundedCornerShape(12.dp)
)
} else {
OutlinedTextField(
value = inputValue,
onValueChange = { inputValue = it },
label = { Text(stringResource(Res.string.delete_account_confirm_password)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isLoading,
visualTransformation = PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp)
)
}
Spacer(modifier = Modifier.height(AppSpacing.sm))
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
// Cancel button
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f),
enabled = !isLoading
) {
Text(stringResource(Res.string.delete_account_cancel))
}
// Delete button
Button(
onClick = {
if (isSocialAuthUser) {
onConfirm(null, inputValue)
} else {
onConfirm(inputValue, null)
}
},
modifier = Modifier.weight(1f),
enabled = isConfirmEnabled && !isLoading,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onError,
strokeWidth = 2.dp
)
} else {
Text(
stringResource(Res.string.delete_account_button),
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
}
}

View File

@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tt.honeyDue.ui.components.HandleErrors import com.tt.honeyDue.ui.components.HandleErrors
import com.tt.honeyDue.ui.components.common.ErrorCard import com.tt.honeyDue.ui.components.common.ErrorCard
import com.tt.honeyDue.ui.components.dialogs.DeleteAccountDialog
import com.tt.honeyDue.ui.components.dialogs.ThemePickerDialog import com.tt.honeyDue.ui.components.dialogs.ThemePickerDialog
import com.tt.honeyDue.utils.SubscriptionHelper import com.tt.honeyDue.utils.SubscriptionHelper
import com.tt.honeyDue.ui.theme.AppRadius import com.tt.honeyDue.ui.theme.AppRadius
@@ -40,6 +41,7 @@ import org.jetbrains.compose.resources.stringResource
fun ProfileScreen( fun ProfileScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onAccountDeleted: () -> Unit = {},
onNavigateToNotificationPreferences: () -> Unit = {}, onNavigateToNotificationPreferences: () -> Unit = {},
onNavigateToUpgrade: (() -> Unit)? = null, onNavigateToUpgrade: (() -> Unit)? = null,
viewModel: AuthViewModel = viewModel { AuthViewModel() } viewModel: AuthViewModel = viewModel { AuthViewModel() }
@@ -53,10 +55,13 @@ fun ProfileScreen(
var isLoadingUser by remember { mutableStateOf(true) } var isLoadingUser by remember { mutableStateOf(true) }
var showThemePicker by remember { mutableStateOf(false) } var showThemePicker by remember { mutableStateOf(false) }
var showUpgradePrompt by remember { mutableStateOf(false) } var showUpgradePrompt by remember { mutableStateOf(false) }
var showDeleteAccountDialog by remember { mutableStateOf(false) }
val updateState by viewModel.updateProfileState.collectAsState() val updateState by viewModel.updateProfileState.collectAsState()
val deleteAccountState by viewModel.deleteAccountState.collectAsState()
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } } val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
val currentSubscription by DataManager.subscription.collectAsState() val currentSubscription by DataManager.subscription.collectAsState()
val currentUser by DataManager.currentUser.collectAsState()
// Handle errors for profile update // Handle errors for profile update
updateState.HandleErrors( updateState.HandleErrors(
@@ -91,6 +96,22 @@ fun ProfileScreen(
} }
} }
// Handle delete account state
LaunchedEffect(deleteAccountState) {
when (deleteAccountState) {
is ApiResult.Success -> {
showDeleteAccountDialog = false
viewModel.resetDeleteAccountState()
onAccountDeleted()
}
is ApiResult.Error -> {
// Error is shown in the dialog via isLoading becoming false
// The user can see the error and retry
}
else -> {}
}
}
// TODO: Re-enable profile update functionality // TODO: Re-enable profile update functionality
/* /*
LaunchedEffect(updateState) { LaunchedEffect(updateState) {
@@ -631,6 +652,47 @@ fun ProfileScreen(
Spacer(modifier = Modifier.height(OrganicSpacing.lg)) Spacer(modifier = Modifier.height(OrganicSpacing.lg))
// Delete Account Section
OrganicDivider(modifier = Modifier.padding(vertical = OrganicSpacing.md))
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { showDeleteAccountDialog = true },
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(AppRadius.md)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
) {
Text(
text = stringResource(Res.string.delete_account_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.error
)
Text(
text = stringResource(Res.string.delete_account_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
)
}
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(Res.string.delete_account_title),
tint = MaterialTheme.colorScheme.error
)
}
}
// App Version Section // App Version Section
OrganicDivider(modifier = Modifier.padding(vertical = OrganicSpacing.md)) OrganicDivider(modifier = Modifier.padding(vertical = OrganicSpacing.md))
Column( Column(
@@ -673,6 +735,25 @@ fun ProfileScreen(
) )
} }
// Delete Account Dialog
if (showDeleteAccountDialog) {
val isSocialAuth = currentUser?.authProvider?.let {
it == "apple" || it == "google"
} ?: false
DeleteAccountDialog(
isSocialAuthUser = isSocialAuth,
onConfirm = { password, confirmation ->
viewModel.deleteAccount(password = password, confirmation = confirmation)
},
onDismiss = {
showDeleteAccountDialog = false
viewModel.resetDeleteAccountState()
},
isLoading = deleteAccountState is ApiResult.Loading
)
}
// Upgrade Prompt Dialog // Upgrade Prompt Dialog
if (showUpgradePrompt) { if (showUpgradePrompt) {
UpgradePromptDialog( UpgradePromptDialog(

View File

@@ -58,6 +58,9 @@ class AuthViewModel : ViewModel() {
private val _googleSignInState = MutableStateFlow<ApiResult<GoogleSignInResponse>>(ApiResult.Idle) private val _googleSignInState = MutableStateFlow<ApiResult<GoogleSignInResponse>>(ApiResult.Idle)
val googleSignInState: StateFlow<ApiResult<GoogleSignInResponse>> = _googleSignInState val googleSignInState: StateFlow<ApiResult<GoogleSignInResponse>> = _googleSignInState
private val _deleteAccountState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val deleteAccountState: StateFlow<ApiResult<Unit>> = _deleteAccountState
fun login(username: String, password: String) { fun login(username: String, password: String) {
viewModelScope.launch { viewModelScope.launch {
_loginState.value = ApiResult.Loading _loginState.value = ApiResult.Loading
@@ -271,4 +274,20 @@ class AuthViewModel : ViewModel() {
APILayer.logout() APILayer.logout()
} }
} }
fun deleteAccount(password: String?, confirmation: String?) {
viewModelScope.launch {
_deleteAccountState.value = ApiResult.Loading
val result = APILayer.deleteAccount(password = password, confirmation = confirmation)
_deleteAccountState.value = when (result) {
is ApiResult.Success -> ApiResult.Success(result.data)
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
}
}
}
fun resetDeleteAccountState() {
_deleteAccountState.value = ApiResult.Idle
}
} }

View File

@@ -56,4 +56,27 @@ class AuthViewModelTest {
// Then // Then
assertIs<ApiResult.Idle>(viewModel.registerState.value) assertIs<ApiResult.Idle>(viewModel.registerState.value)
} }
// MARK: - Delete Account Tests
@Test
fun testInitialDeleteAccountState() {
// Given
val viewModel = AuthViewModel()
// Then
assertIs<ApiResult.Idle>(viewModel.deleteAccountState.value)
}
@Test
fun testResetDeleteAccountState() {
// Given
val viewModel = AuthViewModel()
// When
viewModel.resetDeleteAccountState()
// Then
assertIs<ApiResult.Idle>(viewModel.deleteAccountState.value)
}
} }