Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,10 @@ interface TaskWizardApi {

@PUT("api/v1/users/notifications")
suspend fun updateNotificationSettings(@Body req: NotificationUpdateRequest): Response<Void>

@POST("api/v1/users/deletion")
suspend fun requestAccountDeletion(): Response<Void>

@DELETE("api/v1/users/deletion")
suspend fun cancelAccountDeletion(): Response<Void>
}
6 changes: 5 additions & 1 deletion android/app/src/main/java/com/dkhalife/tasks/model/User.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.dkhalife.tasks.model

import com.google.gson.annotations.SerializedName

data class UserProfile(
val notifications: NotificationSettings = NotificationSettings()
val notifications: NotificationSettings = NotificationSettings(),
@SerializedName("deletion_requested_at")
val deletionRequestedAt: String? = null
)

data class NotificationSettings(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,36 @@ class UserRepository @Inject constructor(
}
}

suspend fun requestDeletion(): Result<Unit> {
return try {
val response = api.requestAccountDeletion()
if (response.isSuccessful) {
Result.success(Unit)
} else {
telemetryManager.logError(TAG, "Failed to request account deletion: ${response.code()}")
Result.failure(Exception("Failed to request account deletion: ${response.code()}"))
}
} catch (e: Exception) {
telemetryManager.logError(TAG, "Failed to request account deletion: ${e.message}", e)
Result.failure(e)
}
}

suspend fun cancelDeletion(): Result<Unit> {
return try {
val response = api.cancelAccountDeletion()
if (response.isSuccessful) {
Result.success(Unit)
} else {
telemetryManager.logError(TAG, "Failed to cancel account deletion: ${response.code()}")
Result.failure(Exception("Failed to cancel account deletion: ${response.code()}"))
}
} catch (e: Exception) {
telemetryManager.logError(TAG, "Failed to cancel account deletion: ${e.message}", e)
Result.failure(e)
}
}

companion object {
private const val TAG = "UserRepository"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import com.dkhalife.tasks.viewmodel.LabelViewModel
import com.dkhalife.tasks.viewmodel.TaskFormViewModel
import com.dkhalife.tasks.viewmodel.TaskHistoryViewModel
import com.dkhalife.tasks.viewmodel.TaskListViewModel
import com.dkhalife.tasks.viewmodel.UserViewModel

@Composable
fun AppNavigation(
Expand Down Expand Up @@ -125,9 +126,11 @@ fun AppNavigation(
) {
composable(Screen.Tasks.route) {
val viewModel: TaskListViewModel = hiltViewModel()
val userViewModel: UserViewModel = hiltViewModel()
val isRefreshing by viewModel.isRefreshing.collectAsState()
val taskGroups by viewModel.taskGroups.collectAsState()
val expandedGroups by viewModel.expandedGroups.collectAsState()
val deletionRequestedAt by userViewModel.deletionRequestedAt.collectAsState()

LaunchedEffect(taskGrouping) {
viewModel.setTaskGrouping(taskGrouping)
Expand All @@ -147,7 +150,8 @@ fun AppNavigation(
onCreateTask = { navController.navigate(Routes.TASK_FORM_CREATE) },
onToggleGroup = { viewModel.toggleGroupExpanded(it) },
swipeSettings = swipeSettings,
inlineCompleteEnabled = inlineCompleteEnabled
inlineCompleteEnabled = inlineCompleteEnabled,
isPendingDeletion = deletionRequestedAt != null
)
}

Expand All @@ -168,8 +172,10 @@ fun AppNavigation(

composable(Screen.Settings.route) {
val authViewModel: AuthViewModel = hiltViewModel()
val userViewModel: UserViewModel = hiltViewModel()
SettingsScreen(
authViewModel = authViewModel,
userViewModel = userViewModel,
themeMode = themeMode,
onThemeModeChanged = onThemeModeChanged,
taskGrouping = taskGrouping,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Insights
import androidx.compose.material.icons.filled.Palette
Expand All @@ -35,7 +36,12 @@ import com.dkhalife.tasks.data.TaskGrouping
import com.dkhalife.tasks.data.ThemeMode
import com.dkhalife.tasks.data.calendar.CalendarRepository
import com.dkhalife.tasks.viewmodel.AuthViewModel
import com.dkhalife.tasks.viewmodel.UserViewModel
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle

private val CALENDAR_PERMISSIONS = arrayOf(
Manifest.permission.READ_CALENDAR,
Expand All @@ -46,6 +52,7 @@ private val CALENDAR_PERMISSIONS = arrayOf(
@Composable
fun SettingsScreen(
authViewModel: AuthViewModel,
userViewModel: UserViewModel,
themeMode: ThemeMode,
onThemeModeChanged: (ThemeMode) -> Unit,
taskGrouping: TaskGrouping,
Expand All @@ -72,6 +79,11 @@ fun SettingsScreen(

var errorMessage by remember { mutableStateOf<String?>(null) }

val deletionRequestedAt by userViewModel.deletionRequestedAt.collectAsState()
val isDeletionLoading by userViewModel.isLoading.collectAsState()
val deletionError by userViewModel.errorMessage.collectAsState()
var showDeletionConfirmDialog by remember { mutableStateOf(false) }

val calendarPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
Expand Down Expand Up @@ -100,6 +112,44 @@ fun SettingsScreen(
}
)
}

if (deletionError != null) {
AlertDialog(
onDismissRequest = { userViewModel.clearError() },
title = { Text(stringResource(R.string.dialog_title_error)) },
text = { Text(deletionError ?: "") },
confirmButton = {
TextButton(onClick = { userViewModel.clearError() }) {
Text(stringResource(R.string.btn_ok))
}
}
)
}

if (showDeletionConfirmDialog) {
AlertDialog(
onDismissRequest = { showDeletionConfirmDialog = false },
icon = { Icon(Icons.Default.DeleteForever, contentDescription = null, tint = MaterialTheme.colorScheme.error) },
title = { Text(stringResource(R.string.dialog_title_confirm_deletion)) },
text = { Text(stringResource(R.string.dialog_confirm_deletion_message)) },
confirmButton = {
TextButton(
onClick = {
showDeletionConfirmDialog = false
userViewModel.requestDeletion()
},
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text(stringResource(R.string.btn_confirm_delete_account))
}
},
dismissButton = {
TextButton(onClick = { showDeletionConfirmDialog = false }) {
Text(stringResource(R.string.btn_cancel))
}
}
)
}
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()

Scaffold(
Expand Down Expand Up @@ -400,6 +450,58 @@ fun SettingsScreen(

Spacer(modifier = Modifier.height(20.dp))

SectionHeader(stringResource(R.string.settings_header_account))

SettingsCard(icon = Icons.Default.DeleteForever, title = stringResource(R.string.settings_section_account_deletion)) {
if (deletionRequestedAt != null) {
val formattedTime = remember(deletionRequestedAt) {
try {
val instant = Instant.parse(deletionRequestedAt)
val deleteAt = instant.plusSeconds(24 * 60 * 60)
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
.withZone(ZoneId.systemDefault())
.format(deleteAt)
} catch (e: Exception) {
deletionRequestedAt ?: ""
}
}
Text(
text = stringResource(R.string.settings_account_deletion_pending_description, formattedTime),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { userViewModel.cancelDeletion() },
enabled = !isDeletionLoading,
modifier = Modifier.fillMaxWidth()
) {
if (isDeletionLoading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.btn_cancel_deletion))
}
} else {
Text(
text = stringResource(R.string.settings_account_deletion_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { showDeletionConfirmDialog = true },
enabled = !isDeletionLoading,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text(stringResource(R.string.btn_delete_account))
}
}
}

Spacer(modifier = Modifier.height(20.dp))

TextButton(
onClick = { authViewModel.signOut() },
modifier = Modifier.fillMaxWidth(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircleOutline
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
Expand Down Expand Up @@ -55,7 +58,8 @@ fun TaskListScreen(
onCreateTask: () -> Unit,
onToggleGroup: (String) -> Unit,
swipeSettings: SwipeSettings = SwipeSettings(),
inlineCompleteEnabled: Boolean = true
inlineCompleteEnabled: Boolean = true,
isPendingDeletion: Boolean = false
){
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val newTaskLabel = stringResource(R.string.btn_new_task)
Expand All @@ -73,22 +77,52 @@ fun TaskListScreen(
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onCreateTask,
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text(newTaskLabel) },
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
if (!isPendingDeletion) {
ExtendedFloatingActionButton(
onClick = onCreateTask,
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text(newTaskLabel) },
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
) { padding ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh,
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (isPendingDeletion) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth()
) {
androidx.compose.foundation.layout.Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.settings_section_account_deletion),
Comment thread
dkhalife marked this conversation as resolved.
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}

PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize()
) {
AnimatedContent(
targetState = taskGroups.isEmpty() && !isRefreshing,
transitionSpec = { fadeIn() togetherWith fadeOut() },
Expand Down Expand Up @@ -141,14 +175,14 @@ fun TaskListScreen(
items(group.tasks, key = { "${group.key}_${it.id}" }) { task ->
TaskItem(
task = task,
onComplete = { onCompleteTask(task.id) },
onSkip = { onSkipTask(task.id) },
onDelete = { onDeleteTask(task.id) },
onClick = { onTaskClick(task.id) },
onComplete = if (isPendingDeletion) ({}) else { { onCompleteTask(task.id) } },
onSkip = if (isPendingDeletion) ({}) else { { onSkipTask(task.id) } },
onDelete = if (isPendingDeletion) ({}) else { { onDeleteTask(task.id) } },
onClick = if (isPendingDeletion) ({}) else { { onTaskClick(task.id) } },
onViewHistory = { onViewHistory(task.id) },
onCompleteAndEndRecurrence = { onCompleteAndEndRecurrenceTask(task.id) },
swipeSettings = swipeSettings,
inlineCompleteEnabled = inlineCompleteEnabled
onCompleteAndEndRecurrence = if (isPendingDeletion) ({}) else { { onCompleteAndEndRecurrenceTask(task.id) } },
swipeSettings = if (isPendingDeletion) SwipeSettings(enabled = false) else swipeSettings,
inlineCompleteEnabled = inlineCompleteEnabled && !isPendingDeletion
)
}
}
Expand All @@ -159,3 +193,4 @@ fun TaskListScreen(
}
}
}
}
Loading
Loading