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.font.FontStyle35import androidx.compose.ui.text.style.TextAlign36import androidx.compose.ui.text.style.TextDecoration37import androidx.compose.ui.unit.dp38import androidx.compose.ui.unit.sp39import androidx.hilt.navigation.compose.hiltViewModel40import androidx.lifecycle.ViewModel41import androidx.lifecycle.viewModelScope42import com.peachpasswords.ui.components.FaviconInitials43import com.peachpasswords.ui.theme.LocalPeachAccent44import com.peachpasswords.ui.theme.peachColors45import com.peachpasswords.ui.theme.PeachType46import com.peachpasswords.ui.theme.ShellBackground47import com.peachpasswords.vault.VaultEntry48import com.peachpasswords.vault.VaultRepository49import com.peachpasswords.vault.domainKey50import dagger.hilt.android.lifecycle.HiltViewModel51import kotlinx.coroutines.flow.MutableStateFlow52import kotlinx.coroutines.flow.StateFlow53import kotlinx.coroutines.flow.asStateFlow54import kotlinx.coroutines.launch55import javax.inject.Inject5657@HiltViewModel58class TrashViewModel @Inject constructor(59    private val vault: VaultRepository,60) : ViewModel() {61    private val _deleted = MutableStateFlow(vault.getDeletedEntries())62    val deleted: StateFlow<List<VaultEntry>> = _deleted.asStateFlow()6364    /** Retention window in days, or null when set to "Never". */65    val retentionDays: Int? get() = vault.settings.value.trashRetentionDays?.takeIf { it > 0 }6667    private fun refresh() { _deleted.value = vault.getDeletedEntries() }6869    fun restore(id: String) = viewModelScope.launch { vault.restoreEntry(id); refresh() }70    fun purge(id: String) = viewModelScope.launch { vault.purgeEntry(id); refresh() }71    fun emptyTrash() = viewModelScope.launch { vault.purgeDeletedEntries(); refresh() }72}7374@Composable75fun TrashScreen(76    onBack: () -> Unit,77    vm: TrashViewModel = hiltViewModel(),78) {79    val deleted by vm.deleted.collectAsState()80    var confirmEmptyAll by remember { mutableStateOf(false) }81    var confirmPurge by remember { mutableStateOf<VaultEntry?>(null) }8283    ShellBackground {84        Column(85            modifier = Modifier86                .fillMaxSize()87                .systemBarsPadding()88                .padding(horizontal = 20.dp, vertical = 16.dp),89        ) {90            // Top bar: back · "Trash" · "Empty trash"91            Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {92                Icon(93                    Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "back", tint = peachColors.T0,94                    modifier = Modifier.size(22.dp).clip(RoundedCornerShape(8.dp)).clickable(onClick = onBack),95                )96                Spacer(Modifier.width(14.dp))97                Text(98                    "Trash",99                    color = peachColors.T0,100                    style = PeachType.Display.let {101                        androidx.compose.ui.text.TextStyle(102                            fontFamily = it, fontStyle = FontStyle.Italic, fontSize = 22.sp,103                        )104                    },105                )106                Spacer(Modifier.weight(1f))107                if (deleted.isNotEmpty()) {108                    GhostPill("Empty trash", peachColors.Breach) { confirmEmptyAll = true }109                }110            }111112            Spacer(Modifier.size(18.dp))113114            if (deleted.isEmpty()) {115                Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {116                    Text(117                        "Trash is empty.",118                        color = peachColors.T1,119                        style = androidx.compose.ui.text.TextStyle(120                            fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 18.sp,121                        ),122                    )123                }124            } else {125                LazyColumn(modifier = Modifier.weight(1f)) {126                    items(deleted, key = { it.id }) { entry ->127                        DeletedRow(128                            entry = entry,129                            onRestore = { vm.restore(entry.id) },130                            onPurge = { confirmPurge = entry },131                        )132                    }133                }134                val days = vm.retentionDays135                if (days != null) {136                    Text(137                        "Entries are permanently deleted after $days days",138                        color = peachColors.T2,139                        style = PeachType.labelMono,140                        textAlign = TextAlign.Center,141                        modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),142                    )143                }144            }145        }146    }147148    if (confirmEmptyAll) {149        ConfirmOverlay(150            message = "Permanently delete all ${deleted.size} entries? This cannot be undone.",151            confirmLabel = "Empty trash",152            onConfirm = { vm.emptyTrash(); confirmEmptyAll = false },153            onCancel = { confirmEmptyAll = false },154        )155    }156    confirmPurge?.let { target ->157        ConfirmOverlay(158            message = "Permanently delete ${target.title}? This cannot be undone.",159            confirmLabel = "Delete",160            onConfirm = { vm.purge(target.id); confirmPurge = null },161            onCancel = { confirmPurge = null },162        )163    }164}165166@Composable167private fun DeletedRow(entry: VaultEntry, onRestore: () -> Unit, onPurge: () -> Unit) {168    Row(169        verticalAlignment = Alignment.CenterVertically,170        modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),171    ) {172        FaviconInitials(source = entry.url ?: entry.title, size = 26.dp)173        Spacer(Modifier.width(12.dp))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(8.dp))187        Icon(188            Icons.Outlined.DeleteOutline, contentDescription = "delete forever", tint = peachColors.Breach,189            modifier = Modifier.size(20.dp).clip(RoundedCornerShape(6.dp)).clickable(onClick = onPurge),190        )191    }192}193194@Composable195private fun GhostPill(label: String, color: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {196    Box(197        modifier = Modifier198            .clip(RoundedCornerShape(999.dp))199            .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(999.dp))200            .clickable(onClick = onClick)201            .padding(horizontal = 12.dp, vertical = 5.dp),202    ) {203        Text(label, color = color, style = PeachType.sortPill.copy(fontSize = 10.sp))204    }205}206207@Composable208private fun ConfirmOverlay(209    message: String,210    confirmLabel: String,211    onConfirm: () -> Unit,212    onCancel: () -> Unit,213) {214    Box(215        modifier = Modifier216            .fillMaxSize()217            .background(androidx.compose.ui.graphics.Color(0xCC000000))218            .clickable(onClick = onCancel),219        contentAlignment = Alignment.Center,220    ) {221        Column(222            modifier = Modifier223                .padding(32.dp)224                .clip(RoundedCornerShape(18.dp))225                .background(peachColors.S2)226                .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))227                .clickable(enabled = false) {}228                .padding(22.dp),229        ) {230            Text(message, color = peachColors.T0, style = PeachType.body)231            Spacer(Modifier.size(18.dp))232            Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {233                GhostPill("Cancel", peachColors.T1, onCancel)234                Spacer(Modifier.width(10.dp))235                GhostPill(confirmLabel, peachColors.Breach, onConfirm)236            }237        }238    }239}