diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 2f1aefa..a64a831 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -556,6 +556,18 @@
honeyDue
Edit Profile
+
+ Delete Account
+ Permanently delete your account
+ This action is permanent and cannot be undone. All your data will be deleted.
+ Any residences you own that are shared with other users will also be deleted.
+ Enter your password to confirm
+ Type DELETE to confirm
+ Delete My Account
+ Cancel
+ Account deleted successfully
+ Failed to delete account
+
Settings
Notification Preferences
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt
index a5b001c..4dc272b 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt
@@ -665,6 +665,15 @@ fun App(
popUpTo { inclusive = true }
}
},
+ onAccountDeleted = {
+ // Clear token and lookups on account deletion
+ DataManager.clear()
+ isLoggedIn = false
+ isVerified = false
+ navController.navigate(LoginRoute) {
+ popUpTo { inclusive = true }
+ }
+ },
onNavigateToNotificationPreferences = {
navController.navigate(NotificationPreferencesRoute)
},
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt
index 17d64f3..fcc61db 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/User.kt
@@ -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
+)
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
index d2c8c11..0acf548 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
@@ -1333,6 +1333,18 @@ object APILayer {
return result
}
+ suspend fun deleteAccount(password: String? = null, confirmation: String? = null): ApiResult {
+ 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 {
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt
index 4614d4a..1049e72 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt
@@ -228,6 +228,26 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
}
}
+ // Delete Account
+ suspend fun deleteAccount(token: String, request: DeleteAccountRequest): ApiResult {
+ 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 {
return try {
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/dialogs/DeleteAccountDialog.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/dialogs/DeleteAccountDialog.kt
new file mode 100644
index 0000000..712cc40
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/dialogs/DeleteAccountDialog.kt
@@ -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
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ProfileScreen.kt
index b3fc686..d4b4ed9 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ProfileScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ProfileScreen.kt
@@ -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(
diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/AuthViewModel.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/AuthViewModel.kt
index 0b576b7..54ff188 100644
--- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/AuthViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/viewmodel/AuthViewModel.kt
@@ -58,6 +58,9 @@ class AuthViewModel : ViewModel() {
private val _googleSignInState = MutableStateFlow>(ApiResult.Idle)
val googleSignInState: StateFlow> = _googleSignInState
+ private val _deleteAccountState = MutableStateFlow>(ApiResult.Idle)
+ val deleteAccountState: StateFlow> = _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
+ }
}
diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/AuthViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/AuthViewModelTest.kt
index 14edc43..7493d10 100644
--- a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/AuthViewModelTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/viewmodel/AuthViewModelTest.kt
@@ -56,4 +56,27 @@ class AuthViewModelTest {
// Then
assertIs(viewModel.registerState.value)
}
+
+ // MARK: - Delete Account Tests
+
+ @Test
+ fun testInitialDeleteAccountState() {
+ // Given
+ val viewModel = AuthViewModel()
+
+ // Then
+ assertIs(viewModel.deleteAccountState.value)
+ }
+
+ @Test
+ fun testResetDeleteAccountState() {
+ // Given
+ val viewModel = AuthViewModel()
+
+ // When
+ viewModel.resetDeleteAccountState()
+
+ // Then
+ assertIs(viewModel.deleteAccountState.value)
+ }
}