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