TrashScreen.kt

9.8 KB · raw

1package com.peachpasswords.ui.vault23import androidx.compose.foundation.background4import androidx.compose.foundation.border5import androidx.compose.foundation.clickable6import androidx.compose.foundation.layout.Arrangement7import androidx.compose.foundation.layout.Box8import androidx.compose.foundation.layout.Column9import androidx.compose.foundation.layout.Row10import androidx.compose.foundation.layout.Spacer11import androidx.compose.foundation.layout.fillMaxSize12import androidx.compose.foundation.layout.fillMaxWidth13import androidx.compose.foundation.layout.padding14import androidx.compose.foundation.layout.size15import androidx.compose.foundation.layout.systemBarsPadding16import androidx.compose.foundation.layout.width17import androidx.compose.foundation.lazy.LazyColumn18import androidx.compose.foundation.lazy.items19import androidx.compose.foundation.shape.RoundedCornerShape20import androidx.compose.material.icons.Icons21import androidx.compose.material.icons.automirrored.rounded.ArrowBack22import androidx.compose.material.icons.outlined.DeleteOutline23import androidx.compose.material3.Icon24import androidx.compose.material3.Text25import androidx.compose.runtime.Composable26import androidx.compose.runtime.collectAsState27import androidx.compose.runtime.getValue28import androidx.compose.runtime.mutableStateOf29import androidx.compose.runtime.remember30import androidx.compose.runtime.setValue31import androidx.compose.ui.Alignment32import androidx.compose.ui.Modifier33import androidx.compose.ui.draw.clip34import androidx.compose.ui.text.style.TextAlign35import androidx.compose.ui.text.style.TextDecoration36import androidx.compose.ui.unit.dp37import androidx.compose.ui.unit.sp38import androidx.hilt.navigation.compose.hiltViewModel39import androidx.lifecycle.ViewModel40import androidx.lifecycle.viewModelScope41import com.peachpasswords.ui.components.FaviconInitials42import com.peachpasswords.ui.components.PeachEmptyOrEndState43import com.peachpasswords.ui.components.PeachTopBar44import com.peachpasswords.ui.theme.LocalPeachAccent45import com.peachpasswords.ui.theme.PeachSpacing46import com.peachpasswords.ui.theme.PeachType47import com.peachpasswords.ui.theme.ShellBackground48import com.peachpasswords.ui.theme.peachColors49import com.peachpasswords.vault.VaultEntry50import com.peachpasswords.vault.VaultRepository51import com.peachpasswords.vault.domainKey52import dagger.hilt.android.lifecycle.HiltViewModel53import kotlinx.coroutines.flow.MutableStateFlow54import kotlinx.coroutines.flow.StateFlow55import kotlinx.coroutines.flow.asStateFlow56import kotlinx.coroutines.launch57import javax.inject.Inject5859@HiltViewModel60class TrashViewModel @Inject constructor(61    private val vault: VaultRepository,62) : ViewModel() {63    private val _deleted = MutableStateFlow(vault.getDeletedEntries())64    val deleted: StateFlow<List<VaultEntry>> = _deleted.asStateFlow()6566    /** Retention window in days, or null when set to "Never". */67    val retentionDays: Int? get() = vault.settings.value.trashRetentionDays?.takeIf { it > 0 }6869    private fun refresh() {70        _deleted.value = vault.getDeletedEntries()71    }7273    fun restore(id: String) = viewModelScope.launch { vault.restoreEntry(id); refresh() }74    fun purge(id: String) = viewModelScope.launch { vault.purgeEntry(id); refresh() }75    fun emptyTrash() = viewModelScope.launch { vault.purgeDeletedEntries(); refresh() }76}7778@Composable79fun TrashScreen(80    onBack: () -> Unit,81    vm: TrashViewModel = hiltViewModel(),82) {83    val deleted by vm.deleted.collectAsState()84    var confirmEmptyAll by remember { mutableStateOf(false) }85    var confirmPurge by remember { mutableStateOf<VaultEntry?>(null) }8687    ShellBackground {88        Column(89            modifier = Modifier90                .fillMaxSize()91                .systemBarsPadding()92                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),93        ) {94            PeachTopBar(95                title = "Trash",96                leading = {97                    Icon(98                        Icons.AutoMirrored.Rounded.ArrowBack,99                        contentDescription = "back",100                        tint = peachColors.T0,101                        modifier = Modifier102                            .size(22.dp)103                            .clip(RoundedCornerShape(PeachSpacing.sm))104                            .clickable(onClick = onBack),105                    )106                },107                actions = {108                    if (deleted.isNotEmpty()) {109                        GhostPill("Empty trash", peachColors.Breach) { confirmEmptyAll = true }110                    }111                },112            )113114            Spacer(Modifier.size(PeachSpacing.lg))115116            if (deleted.isEmpty()) {117                Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {118                    PeachEmptyOrEndState(119                        message = "Trash is empty.",120                        detail = "Deleted entries will wait here before they are gone for good.",121                    )122                }123            } else {124                LazyColumn(modifier = Modifier.weight(1f)) {125                    items(deleted, key = { it.id }) { entry ->126                        DeletedRow(127                            entry = entry,128                            onRestore = { vm.restore(entry.id) },129                            onPurge = { confirmPurge = entry },130                        )131                    }132                    item {133                        val days = vm.retentionDays134                        if (days != null) {135                            Text(136                                "Entries are permanently deleted after $days days",137                                color = peachColors.T2,138                                style = PeachType.labelMono,139                                textAlign = TextAlign.Center,140                                modifier = Modifier141                                    .fillMaxWidth()142                                    .padding(vertical = PeachSpacing.md),143                            )144                        } else {145                            PeachEmptyOrEndState(146                                message = "End of trash.",147                                detail = "Nothing below this point will be removed automatically.",148                            )149                        }150                    }151                }152            }153        }154    }155156    if (confirmEmptyAll) {157        ConfirmOverlay(158            message = "Permanently delete all ${deleted.size} entries? This cannot be undone.",159            confirmLabel = "Empty trash",160            onConfirm = { vm.emptyTrash(); confirmEmptyAll = false },161            onCancel = { confirmEmptyAll = false },162        )163    }164    confirmPurge?.let { target ->165        ConfirmOverlay(166            message = "Permanently delete ${target.title}? This cannot be undone.",167            confirmLabel = "Delete",168            onConfirm = { vm.purge(target.id); confirmPurge = null },169            onCancel = { confirmPurge = null },170        )171    }172}173174@Composable175private fun DeletedRow(entry: VaultEntry, onRestore: () -> Unit, onPurge: () -> Unit) {176    Row(177        verticalAlignment = Alignment.CenterVertically,178        modifier = Modifier179            .fillMaxWidth()180            .padding(vertical = PeachSpacing.sm),181    ) {182        FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)183        Spacer(Modifier.width(PeachSpacing.sm))184        Column(Modifier.weight(1f)) {185            Text(186                entry.title,187                color = peachColors.T1,188                style = PeachType.entryNameList.copy(textDecoration = TextDecoration.LineThrough),189            )190            val domain = entry.domainKey()191            if (!domain.isNullOrBlank()) {192                Text(domain, color = peachColors.T2, style = PeachType.tinyMono)193            }194        }195        GhostPill("Restore", LocalPeachAccent.current, onRestore)196        Spacer(Modifier.width(PeachSpacing.sm))197        Icon(198            Icons.Outlined.DeleteOutline,199            contentDescription = "delete forever",200            tint = peachColors.Breach,201            modifier = Modifier202                .size(20.dp)203                .clip(RoundedCornerShape(6.dp))204                .clickable(onClick = onPurge),205        )206    }207}208209@Composable210private fun GhostPill(label: String, color: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {211    Box(212        modifier = Modifier213            .clip(RoundedCornerShape(999.dp))214            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))215            .clickable(onClick = onClick)216            .padding(horizontal = 12.dp, vertical = 5.dp),217    ) {218        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))219    }220}221222@Composable223private fun ConfirmOverlay(224    message: String,225    confirmLabel: String,226    onConfirm: () -> Unit,227    onCancel: () -> Unit,228) {229    Box(230        modifier = Modifier231            .fillMaxSize()232            .background(androidx.compose.ui.graphics.Color(0xCC000000))233            .clickable(onClick = onCancel),234        contentAlignment = Alignment.Center,235    ) {236        Column(237            modifier = Modifier238                .padding(PeachSpacing.xxl)239                .clip(RoundedCornerShape(PeachSpacing.lg))240                .background(peachColors.S2)241                .border(1.dp, peachColors.T3, RoundedCornerShape(PeachSpacing.lg))242                .clickable(enabled = false) {}243                .padding(22.dp),244        ) {245            Text(message, color = peachColors.T0, style = PeachType.body)246            Spacer(Modifier.size(PeachSpacing.lg))247            Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {248                GhostPill("Cancel", peachColors.T1, onCancel)249                Spacer(Modifier.width(PeachSpacing.sm))250                GhostPill(confirmLabel, peachColors.Breach, onConfirm)251            }252        }253    }254}