TrashScreen.kt

9.4 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.PeachSectionHeader44import 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            PeachSectionHeader(95                section = "trash",96                onHome = onBack,97                actions = {98                    if (deleted.isNotEmpty()) {99                        GhostPill("Empty trash", peachColors.Breach) { confirmEmptyAll = true }100                    }101                },102            )103104            Spacer(Modifier.size(PeachSpacing.lg))105106            if (deleted.isEmpty()) {107                Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {108                    PeachEmptyOrEndState(109                        message = "Trash is empty.",110                        detail = "Deleted entries will wait here before they are gone for good.",111                    )112                }113            } else {114                LazyColumn(modifier = Modifier.weight(1f)) {115                    items(deleted, key = { it.id }) { entry ->116                        DeletedRow(117                            entry = entry,118                            onRestore = { vm.restore(entry.id) },119                            onPurge = { confirmPurge = entry },120                        )121                    }122                    item {123                        val days = vm.retentionDays124                        if (days != null) {125                            Text(126                                "Entries are permanently deleted after $days days",127                                color = peachColors.T2,128                                style = PeachType.labelMono,129                                textAlign = TextAlign.Center,130                                modifier = Modifier131                                    .fillMaxWidth()132                                    .padding(vertical = PeachSpacing.md),133                            )134                        } else {135                            PeachEmptyOrEndState(136                                message = "End of trash.",137                                detail = "Nothing below this point will be removed automatically.",138                            )139                        }140                    }141                }142            }143        }144    }145146    if (confirmEmptyAll) {147        ConfirmOverlay(148            message = "Permanently delete all ${deleted.size} entries? This cannot be undone.",149            confirmLabel = "Empty trash",150            onConfirm = { vm.emptyTrash(); confirmEmptyAll = false },151            onCancel = { confirmEmptyAll = false },152        )153    }154    confirmPurge?.let { target ->155        ConfirmOverlay(156            message = "Permanently delete ${target.title}? This cannot be undone.",157            confirmLabel = "Delete",158            onConfirm = { vm.purge(target.id); confirmPurge = null },159            onCancel = { confirmPurge = null },160        )161    }162}163164@Composable165private fun DeletedRow(entry: VaultEntry, onRestore: () -> Unit, onPurge: () -> Unit) {166    Row(167        verticalAlignment = Alignment.CenterVertically,168        modifier = Modifier169            .fillMaxWidth()170            .padding(vertical = PeachSpacing.sm),171    ) {172        FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)173        Spacer(Modifier.width(PeachSpacing.sm))174        Column(Modifier.weight(1f)) {175            Text(176                entry.title,177                color = peachColors.T1,178                style = PeachType.entryNameList.copy(textDecoration = TextDecoration.LineThrough),179            )180            val domain = entry.domainKey()181            if (!domain.isNullOrBlank()) {182                Text(domain, color = peachColors.T2, style = PeachType.tinyMono)183            }184        }185        GhostPill("Restore", LocalPeachAccent.current, onRestore)186        Spacer(Modifier.width(PeachSpacing.sm))187        Icon(188            Icons.Outlined.DeleteOutline,189            contentDescription = "delete forever",190            tint = peachColors.Breach,191            modifier = Modifier192                .size(20.dp)193                .clip(RoundedCornerShape(6.dp))194                .clickable(onClick = onPurge),195        )196    }197}198199@Composable200private fun GhostPill(label: String, color: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {201    Box(202        modifier = Modifier203            .clip(RoundedCornerShape(999.dp))204            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))205            .clickable(onClick = onClick)206            .padding(horizontal = 12.dp, vertical = 5.dp),207    ) {208        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))209    }210}211212@Composable213private fun ConfirmOverlay(214    message: String,215    confirmLabel: String,216    onConfirm: () -> Unit,217    onCancel: () -> Unit,218) {219    Box(220        modifier = Modifier221            .fillMaxSize()222            .background(androidx.compose.ui.graphics.Color(0xCC000000))223            .clickable(onClick = onCancel),224        contentAlignment = Alignment.Center,225    ) {226        Column(227            modifier = Modifier228                .padding(PeachSpacing.xxl)229                .clip(RoundedCornerShape(PeachSpacing.lg))230                .background(peachColors.S2)231                .border(1.dp, peachColors.T3, RoundedCornerShape(PeachSpacing.lg))232                .clickable(enabled = false) {}233                .padding(22.dp),234        ) {235            Text(message, color = peachColors.T0, style = PeachType.body)236            Spacer(Modifier.size(PeachSpacing.lg))237            Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {238                GhostPill("Cancel", peachColors.T1, onCancel)239                Spacer(Modifier.width(PeachSpacing.sm))240                GhostPill(confirmLabel, peachColors.Breach, onConfirm)241            }242        }243    }244}