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:
@@ -665,6 +665,15 @@ fun App(
|
||||
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 = {
|
||||
navController.navigate(NotificationPreferencesRoute)
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ data class User(
|
||||
@SerialName("is_active") val isActive: Boolean = true,
|
||||
@SerialName("date_joined") val dateJoined: String,
|
||||
@SerialName("last_login") val lastLogin: String? = null,
|
||||
@SerialName("auth_provider") val authProvider: String? = null,
|
||||
// Profile is included in CurrentUserResponse (/auth/me)
|
||||
val profile: UserProfile? = null,
|
||||
// Verified is returned directly in LoginResponse, and also in profile for CurrentUserResponse
|
||||
@@ -206,3 +207,12 @@ data class GoogleSignInResponse(
|
||||
val user: User,
|
||||
@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
|
||||
)
|
||||
|
||||
@@ -1333,6 +1333,18 @@ object APILayer {
|
||||
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 ====================
|
||||
|
||||
suspend fun registerDevice(request: DeviceRegistrationRequest): ApiResult<DeviceRegistrationResponse> {
|
||||
|
||||
@@ -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
|
||||
suspend fun googleSignIn(request: GoogleSignInRequest): ApiResult<GoogleSignInResponse> {
|
||||
return try {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tt.honeyDue.ui.components.HandleErrors
|
||||
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.utils.SubscriptionHelper
|
||||
import com.tt.honeyDue.ui.theme.AppRadius
|
||||
@@ -40,6 +41,7 @@ import org.jetbrains.compose.resources.stringResource
|
||||
fun ProfileScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onAccountDeleted: () -> Unit = {},
|
||||
onNavigateToNotificationPreferences: () -> Unit = {},
|
||||
onNavigateToUpgrade: (() -> Unit)? = null,
|
||||
viewModel: AuthViewModel = viewModel { AuthViewModel() }
|
||||
@@ -53,10 +55,13 @@ fun ProfileScreen(
|
||||
var isLoadingUser by remember { mutableStateOf(true) }
|
||||
var showThemePicker by remember { mutableStateOf(false) }
|
||||
var showUpgradePrompt by remember { mutableStateOf(false) }
|
||||
var showDeleteAccountDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val updateState by viewModel.updateProfileState.collectAsState()
|
||||
val deleteAccountState by viewModel.deleteAccountState.collectAsState()
|
||||
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
||||
val currentSubscription by DataManager.subscription.collectAsState()
|
||||
val currentUser by DataManager.currentUser.collectAsState()
|
||||
|
||||
// Handle errors for profile update
|
||||
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
|
||||
/*
|
||||
LaunchedEffect(updateState) {
|
||||
@@ -631,6 +652,47 @@ fun ProfileScreen(
|
||||
|
||||
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
|
||||
OrganicDivider(modifier = Modifier.padding(vertical = OrganicSpacing.md))
|
||||
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
|
||||
if (showUpgradePrompt) {
|
||||
UpgradePromptDialog(
|
||||
|
||||
@@ -58,6 +58,9 @@ class AuthViewModel : ViewModel() {
|
||||
private val _googleSignInState = MutableStateFlow<ApiResult<GoogleSignInResponse>>(ApiResult.Idle)
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
_loginState.value = ApiResult.Loading
|
||||
@@ -271,4 +274,20 @@ class AuthViewModel : ViewModel() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user